diff --git a/aftman.toml b/aftman.toml index 2299c86c1e..3c19db2edd 100644 --- a/aftman.toml +++ b/aftman.toml @@ -5,7 +5,7 @@ [tools] rojo = "quenty/rojo@7.3.0-quenty-npm-canary.7" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" -selene = "Kampfkarren/selene@0.23.1" +selene = "Kampfkarren/selene@0.25.0" moonwave-extractor = "UpliftGames/moonwave@1.0.1" mantle = "blake-mealey/mantle@0.10.7" remodel = "rojo-rbx/remodel@0.9.1" \ No newline at end of file diff --git a/games/integration/aftman.toml b/games/integration/aftman.toml index b5ea6b5891..625a9fd3d9 100644 --- a/games/integration/aftman.toml +++ b/games/integration/aftman.toml @@ -2,4 +2,4 @@ # For more information, see https://github.com/LPGhatguy/aftman [tools] rojo = "quenty/rojo@7.3.0-quenty-npm-canary.7" -selene = "Kampfkarren/selene@0.23.1" \ No newline at end of file +selene = "Kampfkarren/selene@0.25.0" \ No newline at end of file diff --git a/readme.md b/readme.md index c1e7b8bc35..c25e7cf277 100644 --- a/readme.md +++ b/readme.md @@ -55,7 +55,7 @@ Many of these packages represent not just useful code, but useful patterns, or w ### All packages -There are 246 packages in Nevermore. +There are 250 packages in Nevermore. | Package | Description | Install | docs | source | changelog | npm | | ------- | ----------- | ------- | ---- | ------ | --------- | --- | @@ -115,9 +115,11 @@ There are 246 packages in Nevermore. | [DataStore](https://quenty.github.io/NevermoreEngine/api/DataStore) | Quenty's Datastore implementation for Roblox | `npm i @quenty/datastore` | [docs](https://quenty.github.io/NevermoreEngine/api/DataStore) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/datastore) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/datastore/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/datastore) | | [DeathReport](https://quenty.github.io/NevermoreEngine/api/DeathReportService) | Death report service which will track the deaths of players | `npm i @quenty/deathreport` | [docs](https://quenty.github.io/NevermoreEngine/api/DeathReportService) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/deathreport) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/deathreport/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/deathreport) | | [debounce](https://quenty.github.io/NevermoreEngine/api/debounce) | debounce a existing function by timeout | `npm i @quenty/debounce` | [docs](https://quenty.github.io/NevermoreEngine/api/debounce) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/debounce) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/debounce/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/debounce) | +| [DefaultValueUtils](https://quenty.github.io/NevermoreEngine/api/DefaultValueUtils) | Helps get the default or zero value for value types in Roblox | `npm i @quenty/defaultvalueutils` | [docs](https://quenty.github.io/NevermoreEngine/api/DefaultValueUtils) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/defaultvalueutils) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/defaultvalueutils/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/defaultvalueutils) | | [Deferred](https://quenty.github.io/NevermoreEngine/api/deferred) | deferred (otherwise known as fastSpawn) implementation for Roblox | `npm i @quenty/deferred` | [docs](https://quenty.github.io/NevermoreEngine/api/deferred) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/deferred) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/deferred/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/deferred) | | [DepthOfField](https://quenty.github.io/NevermoreEngine/api/DepthOfFieldService) | Depth of field service to allow multiple systems to write depth of field | `npm i @quenty/depthoffield` | [docs](https://quenty.github.io/NevermoreEngine/api/DepthOfFieldService) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/depthoffield) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/depthoffield/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/depthoffield) | | [Draw](https://quenty.github.io/NevermoreEngine/api/Draw) | A utility library to debug things in 3D space for Roblox. | `npm i @quenty/draw` | [docs](https://quenty.github.io/NevermoreEngine/api/Draw) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/draw) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/draw/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/draw) | +| [DuckType](https://quenty.github.io/NevermoreEngine/api/DuckTypeUtils) | Utility functions to duck type a value | `npm i @quenty/ducktype` | [docs](https://quenty.github.io/NevermoreEngine/api/DuckTypeUtils) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/ducktype) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/ducktype/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/ducktype) | | [EllipticCurveCryptography](https://quenty.github.io/NevermoreEngine/api/EllipticCurveCryptography) | Elliptic curve cryptography forked from BoatBomber, forked from ComputerCraft | `npm i @quenty/ellipticcurvecryptography` | [docs](https://quenty.github.io/NevermoreEngine/api/EllipticCurveCryptography) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/ellipticcurvecryptography) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/ellipticcurvecryptography/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/ellipticcurvecryptography) | | [Elo](https://quenty.github.io/NevermoreEngine/api/EloUtils) | Elo rating utility library. | `npm i @quenty/elo` | [docs](https://quenty.github.io/NevermoreEngine/api/EloUtils) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/elo) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/elo/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/elo) | | [EnabledMixin](https://quenty.github.io/NevermoreEngine/api/EnabledMixin) | Adds Enabled/Disabled state to class | `npm i @quenty/enabledmixin` | [docs](https://quenty.github.io/NevermoreEngine/api/EnabledMixin) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/enabledmixin) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/enabledmixin/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/enabledmixin) | @@ -266,6 +268,7 @@ There are 246 packages in Nevermore. | [Snackbar](https://quenty.github.io/NevermoreEngine/api/Snackbar) | Snackbars provide lightweight feedback on an operation at the base of the screen. They automatically disappear after a timeout or user interaction. There can only be one on the screen at a time. | `npm i @quenty/snackbar` | [docs](https://quenty.github.io/NevermoreEngine/api/Snackbar) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/snackbar) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/snackbar/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/snackbar) | | [SocialServiceUtils](https://quenty.github.io/NevermoreEngine/api/SocialServiceUtils) | Utility functions wrapping SocialService with promises | `npm i @quenty/socialserviceutils` | [docs](https://quenty.github.io/NevermoreEngine/api/SocialServiceUtils) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/socialserviceutils) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/socialserviceutils/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/socialserviceutils) | | [SoftShutdown](https://quenty.github.io/NevermoreEngine/api/SoftShutdownService) | This service lets you shut down servers without losing a bunch of players. When game.OnClose is called, the script teleports everyone in the server into a reserved server. | `npm i @quenty/softshutdown` | [docs](https://quenty.github.io/NevermoreEngine/api/SoftShutdownService) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/softshutdown) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/softshutdown/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/softshutdown) | +| [SoundPlayer](https://quenty.github.io/NevermoreEngine/api/LoopedSoundPlayer) | Sound playback helper | `npm i @quenty/soundplayer` | [docs](https://quenty.github.io/NevermoreEngine/api/LoopedSoundPlayer) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/soundplayer) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/soundplayer/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/soundplayer) | | [SoundPromiseUtils](https://quenty.github.io/NevermoreEngine/api/SoundUtils) | Utility functions involving sounds and their state | `npm i @quenty/sounds` | [docs](https://quenty.github.io/NevermoreEngine/api/SoundUtils) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/sounds) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/sounds/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/sounds) | | [SoundPromiseUtils](https://quenty.github.io/NevermoreEngine/api/SpawnService) | Centralized spawning system | `npm i @quenty/spawning` | [docs](https://quenty.github.io/NevermoreEngine/api/SpawnService) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/spawning) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/spawning/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/spawning) | | [Spring](https://quenty.github.io/NevermoreEngine/api/Spring) | Spring implementation for Roblox | `npm i @quenty/spring` | [docs](https://quenty.github.io/NevermoreEngine/api/Spring) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/spring) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/spring/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/spring) | @@ -289,6 +292,7 @@ There are 246 packages in Nevermore. | [Throttle](https://quenty.github.io/NevermoreEngine/api/throttle) | Adds the throttle function to Roblox | `npm i @quenty/throttle` | [docs](https://quenty.github.io/NevermoreEngine/api/throttle) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/throttle) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/throttle/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/throttle) | | [Tie](https://quenty.github.io/NevermoreEngine/api/TieInterface) | Tie allows interfaces to be defined between Lua OOP and Roblox objects. | `npm i @quenty/tie` | [docs](https://quenty.github.io/NevermoreEngine/api/TieInterface) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/tie) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/tie/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/tie) | | [Time](https://quenty.github.io/NevermoreEngine/api/Time) | Library handles time based parsing / operations. Untested. Based off of PHP's time system. Note: This library is out of date, and does not necessarily work. I recommend using os.time() | `npm i @quenty/time` | [docs](https://quenty.github.io/NevermoreEngine/api/Time) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/time) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/time/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/time) | +| [TimedTween](https://quenty.github.io/NevermoreEngine/api/TimedTweenUtils) | Linear timed tweening model | `npm i @quenty/timedtween` | [docs](https://quenty.github.io/NevermoreEngine/api/TimedTweenUtils) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/timedtween) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/timedtween/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/timedtween) | | [TimeSyncService](https://quenty.github.io/NevermoreEngine/api/TimeSyncService) | Quenty's TimeSyncService keeps time synchronized between all clients and the server | `npm i @quenty/timesyncservice` | [docs](https://quenty.github.io/NevermoreEngine/api/TimeSyncService) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/timesyncservice) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/timesyncservice/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/timesyncservice) | | [TouchingPartUtils](https://quenty.github.io/NevermoreEngine/api/TouchingPartUtils) | Utility to get touching parts on a Roblox part. This acts as a performance-friendly way to query Roblox's spatial tree. | `npm i @quenty/touchingpartutils` | [docs](https://quenty.github.io/NevermoreEngine/api/TouchingPartUtils) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/touchingpartutils) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/touchingpartutils/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/touchingpartutils) | | [trajectory](https://quenty.github.io/NevermoreEngine/api/trajectory) | Utility function for estimating low and high arcs of projectiles. Solves for bullet drop given | `npm i @quenty/trajectory` | [docs](https://quenty.github.io/NevermoreEngine/api/trajectory) | [source](https://github.com/Quenty/NevermoreEngine/tree/main/src/trajectory) | [changelog](https://github.com/Quenty/NevermoreEngine/tree/main/src/trajectory/CHANGELOG.md) | [npm](https://www.npmjs.com/package/@quenty/trajectory) | diff --git a/src/acceltween/src/Shared/AccelTween.lua b/src/acceltween/src/Shared/AccelTween.lua index 3bcbf0ac10..5075af1ef1 100644 --- a/src/acceltween/src/Shared/AccelTween.lua +++ b/src/acceltween/src/Shared/AccelTween.lua @@ -82,17 +82,17 @@ function AccelTween:__index(index) if AccelTween[index] then return AccelTween[index] elseif index == "p" then - local pos, _ = self:_getstate(tick()) + local pos, _ = self:_getstate(os.clock()) return pos elseif index == "v" then - local _, vel = self:_getstate(tick()) + local _, vel = self:_getstate(os.clock()) return vel elseif index == "a" then return self._accel elseif index == "t" then return self._y1 elseif index == "rtime" then - local time = tick() + local time = os.clock() return time < self._t1 and self._t1 - time or 0 else error(("Bad index %q"):format(tostring(index))) @@ -128,7 +128,7 @@ function AccelTween:_getstate(time) end function AccelTween:_setstate(newpos, newvel, newaccel, newtarg) - local time = tick() + local time = os.clock() local pos, vel = self:_getstate(time) pos = newpos or pos vel = newvel or vel diff --git a/src/animations/package.json b/src/animations/package.json index b28aedf23b..dee0c24840 100644 --- a/src/animations/package.json +++ b/src/animations/package.json @@ -32,6 +32,8 @@ "@quenty/maid": "file:../maid", "@quenty/promisemaid": "file:../promisemaid", "@quenty/rbxasset": "file:../rbxasset", + "@quenty/rx": "file:../rx", + "@quenty/signal": "file:../signal", "@quenty/valueobject": "file:../valueobject" }, "publishConfig": { diff --git a/src/animations/src/Shared/AnimationSlotPlayer.lua b/src/animations/src/Shared/AnimationSlotPlayer.lua index c296658b93..7dcc901656 100644 --- a/src/animations/src/Shared/AnimationSlotPlayer.lua +++ b/src/animations/src/Shared/AnimationSlotPlayer.lua @@ -65,9 +65,13 @@ function AnimationSlotPlayer:Play(id, fadeTime, weight, speed, priority) local maid = brio:ToMaid() local track = AnimationUtils.playAnimation(animationTarget, id, fadeTime, weight, speed, priority) - maid:GiveTask(function() - track:AdjustWeight(0, fadeTime or self._defaultFadeTime.Value) - end) + if track then + maid:GiveTask(function() + track:AdjustWeight(0, fadeTime or self._defaultFadeTime.Value) + end) + else + warn("[AnimationSlotPlayer] - Failed to get animation to play") + end end)) self._maid._current = topMaid diff --git a/src/animations/src/Shared/AnimationTrackPlayer.lua b/src/animations/src/Shared/AnimationTrackPlayer.lua new file mode 100644 index 0000000000..5855dcb3d2 --- /dev/null +++ b/src/animations/src/Shared/AnimationTrackPlayer.lua @@ -0,0 +1,155 @@ +--[=[ + @class AnimationTrackPlayer +]=] + +local require = require(script.Parent.loader).load(script) + +local BaseObject = require("BaseObject") +local ValueObject = require("ValueObject") +local Rx = require("Rx") +local AnimationUtils = require("AnimationUtils") +local Signal = require("Signal") + +local AnimationTrackPlayer = setmetatable({}, BaseObject) +AnimationTrackPlayer.ClassName = "AnimationTrackPlayer" +AnimationTrackPlayer.__index = AnimationTrackPlayer + +function AnimationTrackPlayer.new(animationTarget, animationId) + local self = setmetatable(BaseObject.new(), AnimationTrackPlayer) + + self._animationTarget = ValueObject.new(nil) + self._maid:GiveTask(self._animationTarget) + + self._trackId = ValueObject.new(nil) + self._maid:GiveTask(self._trackId) + + self._currentTrack = ValueObject.new(nil) + self._maid:GiveTask(self._currentTrack) + + self.KeyframeReached = Signal.new() + self._maid:GiveTask(self.KeyframeReached) + + self._animationPriority = ValueObject.new(nil) + self._maid:GiveTask(self._animationPriority) + + if animationTarget then + self:SetAnimationTarget(animationTarget) + end + + if animationId then + self:SetAnimationId(animationId) + end + + self:_setupState() + + return self +end + +function AnimationTrackPlayer:_setupState() + self._maid:GiveTask(Rx.combineLatest({ + animationTarget = self._animationTarget:Observe(); + trackId = self._trackId:Observe(); + animationPriority = self._animationPriority:Observe(); + }):Pipe({ + Rx.throttleDefer(); + }):Subscribe(function(state) + if state.animationTarget and state.trackId then + self._currentTrack.Value = AnimationUtils.getOrCreateAnimationTrack(state.animationTarget, state.trackId, state.animationPriority) + else + self._currentTrack.Value = nil + end + end)) + + self._maid:GiveTask(self._currentTrack:ObserveBrio(function(track) + return track ~= nil + end):Subscribe(function(brio) + if brio:IsDead() then + return + end + + local maid = brio:ToMaid() + local track = brio:GetValue() + + maid:GiveTask(track.KeyframeReached:Connect(function(...) + self.KeyframeReached:Fire(...) + end)) + end)) +end + +function AnimationTrackPlayer:SetAnimationId(animationId) + return self._trackId:Mount(animationId) +end + +function AnimationTrackPlayer:GetAnimationId() + return self._trackId.Value +end + +function AnimationTrackPlayer:SetAnimationTarget(animationTarget) + return self._animationTarget:Mount(animationTarget) +end + +function AnimationTrackPlayer:SetWeightTargetIfNotSet(weight, fadeTime) + self._maid._adjustWeight = self:_onEachTrack(function(_maid, track) + if track.WeightTarget ~= weight then + track:AdjustWeight(weight, fadeTime) + end + end) +end + +function AnimationTrackPlayer:Play(fadeTime, weight, speed) + if weight then + self._maid._adjustWeight = nil + end + + if speed then + self._maid._adjustSpeed = nil + end + + self._maid._stop = nil + self._maid._play = self:_onEachTrack(function(_maid, track) + track:Play(fadeTime, weight, speed) + end) +end + +function AnimationTrackPlayer:Stop(fadeTime) + self._maid._play = nil + self._maid._stop = self:_onEachTrack(function(_maid, track) + track:Stop(fadeTime) + end) +end + +function AnimationTrackPlayer:AdjustWeight(weight, fadeTime) + self._maid._adjustWeight = self:_onEachTrack(function(_maid, track) + track:AdjustWeight(weight, fadeTime) + end) +end + +function AnimationTrackPlayer:AdjustSpeed(speed, fadeTime) + self._maid._adjustSpeed = self:_onEachTrack(function(_maid, track) + track:AdjustSpeed(speed, fadeTime) + end) +end + +function AnimationTrackPlayer:IsPlaying() + local track = self._currentTrack.Value + if track then + return track.IsPlaying + else + return false + end +end + +function AnimationTrackPlayer:_onEachTrack(callback) + return self._currentTrack:ObserveBrio(function(track) + return track ~= nil + end):Subscribe(function(brio) + if brio:IsDead() then + return + end + + local track = brio:GetValue() + callback(brio:ToMaid(), track) + end) +end + +return AnimationTrackPlayer \ No newline at end of file diff --git a/src/animations/src/Shared/Testing/StudioRigAnimator.lua b/src/animations/src/Shared/Testing/StudioRigAnimator.lua new file mode 100644 index 0000000000..80d998d232 --- /dev/null +++ b/src/animations/src/Shared/Testing/StudioRigAnimator.lua @@ -0,0 +1,41 @@ +--[=[ + Ship to run animations in hoarcekat + + @class StudioRigAnimator +]=] + +local require = require(script.Parent.loader).load(script) + +local RunService = game:GetService("RunService") + +local BaseObject = require("BaseObject") +local AnimationUtils = require("AnimationUtils") + +local StudioRigAnimator = setmetatable({}, BaseObject) +StudioRigAnimator.ClassName = "StudioRigAnimator" +StudioRigAnimator.__index = StudioRigAnimator + +function StudioRigAnimator.new(animatorOrHumanoid) + local self = setmetatable(BaseObject.new(animatorOrHumanoid), StudioRigAnimator) + + if RunService:IsStudio() and not RunService:IsRunning() then + self:_setupStudio() + end + + return self +end + +function StudioRigAnimator:_setupStudio() + self._animator = AnimationUtils.getOrCreateAnimator(self._obj) + self._lastTime = os.clock() + + self._maid:GiveTask(RunService.RenderStepped:Connect(function() + local now = os.clock() + local delta = now - self._lastTime + self._lastTime = now + + self._animator:StepAnimations(delta) + end)) +end + +return StudioRigAnimator \ No newline at end of file diff --git a/src/binder/src/Shared/Binder.lua b/src/binder/src/Shared/Binder.lua index 5f198aa0ce..60af1bd902 100644 --- a/src/binder/src/Shared/Binder.lua +++ b/src/binder/src/Shared/Binder.lua @@ -223,6 +223,36 @@ function Binder:Observe(instance) end) end +--[=[ + Observes all entries in the binder + + @return Observable> +]=] +function Binder:ObserveAllBrio() + return Observable.new(function(sub) + local maid = Maid.new() + + local function handleNewClass(class) + local brio = Brio.new(class) + maid[class] = brio + + sub:Fire(brio) + end + + maid:GiveTask(self:GetClassAddedSignal():Connect(handleNewClass)) + + for _, item in pairs(self:GetAll()) do + handleNewClass(item) + end + + maid:GiveTask(self:GetClassRemovingSignal():Connect(function(class) + maid[class] = nil + end)) + + return maid + end) +end + --[=[ Observes a bound class on a given instance. diff --git a/src/blend/src/Shared/Blend/BlendDefaultProps.lua b/src/blend/src/Shared/Blend/BlendDefaultProps.lua index fce146cf5e..8d6f7c9b09 100644 --- a/src/blend/src/Shared/Blend/BlendDefaultProps.lua +++ b/src/blend/src/Shared/Blend/BlendDefaultProps.lua @@ -128,4 +128,8 @@ return { UIListLayout = { SortOrder = Enum.SortOrder.LayoutOrder; }, + + Sound = { + RollOffMode = Enum.RollOffMode.InverseTapered; + }; } \ No newline at end of file diff --git a/src/brio/src/Shared/RxBrioUtils.lua b/src/brio/src/Shared/RxBrioUtils.lua index 7d35261128..0078a8c438 100644 --- a/src/brio/src/Shared/RxBrioUtils.lua +++ b/src/brio/src/Shared/RxBrioUtils.lua @@ -31,6 +31,18 @@ function RxBrioUtils.toBrio() end) end +--[=[ + Same as [Rx.of] but wraps it in a Brio. + + @param ... T + @return Observable> +]=] +function RxBrioUtils.of(...) + return Rx.of(...):Pipe({ + RxBrioUtils.toBrio() + }) +end + --[=[ Completes the observable on death diff --git a/src/characterutils/src/Shared/RxRootPartUtils.lua b/src/characterutils/src/Shared/RxRootPartUtils.lua index dfdd294966..4775e93861 100644 --- a/src/characterutils/src/Shared/RxRootPartUtils.lua +++ b/src/characterutils/src/Shared/RxRootPartUtils.lua @@ -5,6 +5,7 @@ local require = require(script.Parent.loader).load(script) local RxInstanceUtils = require("RxInstanceUtils") +local RxBrioUtils = require("RxBrioUtils") local RxRootPartUtils = {} @@ -18,4 +19,20 @@ function RxRootPartUtils.observeHumanoidRootPartBrio(character) return RxInstanceUtils.observeLastNamedChildBrio(character, "BasePart", "HumanoidRootPart") end +--[=[ + Observes the last humanoid root part of a character + + @param humanoid Humanoid + @return Brio +]=] +function RxRootPartUtils.observeHumanoidRootPartBrioFromHumanoid(humanoid) + return RxInstanceUtils.observePropertyBrio(humanoid, "Parent", function(character) + return character ~= nil + end):Pipe({ + RxBrioUtils.switchMapBrio(function(character) + return RxRootPartUtils.observeHumanoidRootPartBrio(character) + end) + }) +end + return RxRootPartUtils \ No newline at end of file diff --git a/src/chatproviderservice/package.json b/src/chatproviderservice/package.json index 2bd7d625e8..321d8a20ef 100644 --- a/src/chatproviderservice/package.json +++ b/src/chatproviderservice/package.json @@ -25,9 +25,28 @@ "Quenty" ], "dependencies": { + "@quenty/attributeutils": "file:../attributeutils", + "@quenty/baseobject": "file:../baseobject", + "@quenty/binder": "file:../binder", + "@quenty/brio": "file:../brio", + "@quenty/clienttranslator": "file:../clienttranslator", + "@quenty/cmdrservice": "file:../cmdrservice", + "@quenty/color3utils": "file:../color3utils", + "@quenty/instanceutils": "file:../instanceutils", "@quenty/loader": "file:../loader", + "@quenty/localizedtextutils": "file:../localizedtextutils", "@quenty/maid": "file:../maid", - "@quenty/promise": "file:../promise" + "@quenty/permissionprovider": "file:../permissionprovider", + "@quenty/playerbinder": "file:../playerbinder", + "@quenty/playerutils": "file:../playerutils", + "@quenty/promise": "file:../promise", + "@quenty/richtext": "file:../richtext", + "@quenty/rx": "file:../rx", + "@quenty/rxbinderutils": "file:../rxbinderutils", + "@quenty/servicebag": "file:../servicebag", + "@quenty/string": "file:../string", + "@quenty/table": "file:../table", + "@quenty/valueobject": "file:../valueobject" }, "publishConfig": { "access": "public" diff --git a/src/chatproviderservice/src/Client/Binders/ChatTagClient.lua b/src/chatproviderservice/src/Client/Binders/ChatTagClient.lua new file mode 100644 index 0000000000..da3bc29e61 --- /dev/null +++ b/src/chatproviderservice/src/Client/Binders/ChatTagClient.lua @@ -0,0 +1,22 @@ +--[=[ + @class ChatTagClient +]=] + +local require = require(script.Parent.loader).load(script) + +local ChatTagBase = require("ChatTagBase") +local Binder = require("Binder") + +local ChatTagClient = setmetatable({}, ChatTagBase) +ChatTagClient.ClassName = "ChatTagClient" +ChatTagClient.__index = ChatTagClient + +function ChatTagClient.new(folder, serviceBag) + local self = setmetatable(ChatTagBase.new(folder), ChatTagClient) + + self._serviceBag = assert(serviceBag, "No serviceBag") + + return self +end + +return Binder.new("ChatTag", ChatTagClient) \ No newline at end of file diff --git a/src/chatproviderservice/src/Client/Binders/HasChatTagsClient.lua b/src/chatproviderservice/src/Client/Binders/HasChatTagsClient.lua new file mode 100644 index 0000000000..bb0d3c6872 --- /dev/null +++ b/src/chatproviderservice/src/Client/Binders/HasChatTagsClient.lua @@ -0,0 +1,65 @@ +--[=[ + @class HasChatTagsClient +]=] + +local require = require(script.Parent.loader).load(script) + +local Binder = require("Binder") +local ChatProviderTranslator = require("ChatProviderTranslator") +local ChatTagClient = require("ChatTagClient") +local Color3Utils = require("Color3Utils") +local HasChatTagsBase = require("HasChatTagsBase") +local LocalizedTextUtils = require("LocalizedTextUtils") +local RichTextUtils = require("RichTextUtils") + +local HasChatTagsClient = setmetatable({}, HasChatTagsBase) +HasChatTagsClient.ClassName = "HasChatTagsClient" +HasChatTagsClient.__index = HasChatTagsClient + +function HasChatTagsClient.new(player, serviceBag) + local self = setmetatable(HasChatTagsBase.new(player), HasChatTagsClient) + + self._serviceBag = assert(serviceBag, "No serviceBag") + self._chatTagBinder = self._serviceBag:GetService(ChatTagClient) + self._translator = self._serviceBag:GetService(ChatProviderTranslator) + + return self +end + +function HasChatTagsClient:GetChatTagBinder() + return self._chatTagBinder +end + +function HasChatTagsClient:GetAsRichText() + local lastChatTags = self._lastChatTags.Value + if not (lastChatTags and #lastChatTags > 0) then + return nil + end + + local output = "" + for index, tagData in pairs(lastChatTags) do + output = output .. string.format("", Color3Utils.toWebHexString(tagData.TagColor)) + + local translatedText + if tagData.TagLocalizedText then + translatedText = LocalizedTextUtils.localizedTextToString(self._translator, tagData.TagLocalizedText) + else + translatedText = tagData.TagText + end + + output = output .. RichTextUtils.sanitizeRichText(translatedText) + + if index ~= #lastChatTags then + output = output .. " " + end + + output = output .. "" + end + + output = output .. "" + + return output +end + + +return Binder.new("HasChatTags", HasChatTagsClient) \ No newline at end of file diff --git a/src/chatproviderservice/src/Client/ChatProviderServiceClient.lua b/src/chatproviderservice/src/Client/ChatProviderServiceClient.lua new file mode 100644 index 0000000000..8b17f42a53 --- /dev/null +++ b/src/chatproviderservice/src/Client/ChatProviderServiceClient.lua @@ -0,0 +1,64 @@ +--[=[ + @class ChatProviderServiceClient +]=] + +local require = require(script.Parent.loader).load(script) + +local TextChatService = game:GetService("TextChatService") +local Players = game:GetService("Players") + +local String = require("String") + +local ChatProviderServiceClient = {} +ChatProviderServiceClient.ServiceName = "ChatProviderServiceClient" + +function ChatProviderServiceClient:Init(serviceBag) + assert(not self._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + + -- External + self._serviceBag:GetService(require("CmdrServiceClient")) + + + -- Binders + self._serviceBag:GetService(require("ChatTagClient")) + self._serviceBag:GetService(require("ChatProviderTranslator")) + self._hasChatTagsBinder = self._serviceBag:GetService(require("HasChatTagsClient")) +end + +function ChatProviderServiceClient:Start() + TextChatService.OnIncomingMessage = function(textChatMessage) + local textSource = textChatMessage.TextSource + if not textSource then + return + end + + local tags = self:_renderTags(textSource) + if tags then + local properties = Instance.new("TextChatMessageProperties") + local name = String.removePostfix(textChatMessage.PrefixText, ":") + + properties.PrefixText = name .. " " .. tags .. ":" + + return properties + end + end +end + +function ChatProviderServiceClient:_renderTags(textSource) + local player = Players:GetPlayerByUserId(textSource.UserId) + if not player then + return nil + end + + local hasChatTags = self._hasChatTagsBinder:Get(player) + if not hasChatTags then + warn("[ChatProviderServiceClient._renderTags] - No HasChatTags") + return nil + end + + return hasChatTags:GetAsRichText() +end + + +return ChatProviderServiceClient \ No newline at end of file diff --git a/src/chatproviderservice/src/Client/ChatProviderTranslator.lua b/src/chatproviderservice/src/Client/ChatProviderTranslator.lua new file mode 100644 index 0000000000..0dd1a540a8 --- /dev/null +++ b/src/chatproviderservice/src/Client/ChatProviderTranslator.lua @@ -0,0 +1,12 @@ +--[[ + @class ChatProviderTranslator +]] + +local require = require(script.Parent.loader).load(script) + +return require("JSONTranslator").new("ChatProviderTranslator", "en", { + chatTags = { + dev = "(dev)"; + mod = "(mod)"; + }; +}) \ No newline at end of file diff --git a/src/chatproviderservice/src/Server/Binders/ChatTag.lua b/src/chatproviderservice/src/Server/Binders/ChatTag.lua new file mode 100644 index 0000000000..8f0be55029 --- /dev/null +++ b/src/chatproviderservice/src/Server/Binders/ChatTag.lua @@ -0,0 +1,22 @@ +--[=[ + @class ChatTag +]=] + +local require = require(script.Parent.loader).load(script) + +local ChatTagBase = require("ChatTagBase") +local Binder = require("Binder") + +local ChatTag = setmetatable({}, ChatTagBase) +ChatTag.ClassName = "ChatTag" +ChatTag.__index = ChatTag + +function ChatTag.new(folder, serviceBag) + local self = setmetatable(ChatTagBase.new(folder), ChatTag) + + self._serviceBag = assert(serviceBag, "No serviceBag") + + return self +end + +return Binder.new("ChatTag", ChatTag) \ No newline at end of file diff --git a/src/chatproviderservice/src/Server/Binders/HasChatTags.lua b/src/chatproviderservice/src/Server/Binders/HasChatTags.lua new file mode 100644 index 0000000000..3c3fc33e37 --- /dev/null +++ b/src/chatproviderservice/src/Server/Binders/HasChatTags.lua @@ -0,0 +1,80 @@ +--[=[ + @class HasChatTags +]=] + +local require = require(script.Parent.loader).load(script) + +local ChatProviderService = require("ChatProviderService") +local ChatTag = require("ChatTag") +local ChatTagConstants = require("ChatTagConstants") +local ChatTagDataUtils = require("ChatTagDataUtils") +local HasChatTagsBase = require("HasChatTagsBase") +local HasChatTagsConstants = require("HasChatTagsConstants") +local LocalizedTextUtils = require("LocalizedTextUtils") +local PlayerBinder = require("PlayerBinder") +local String = require("String") + +local HasChatTags = setmetatable({}, HasChatTagsBase) +HasChatTags.ClassName = "HasChatTags" +HasChatTags.__index = HasChatTags + +function HasChatTags.new(player, serviceBag) + local self = setmetatable(HasChatTagsBase.new(player), HasChatTags) + + self._serviceBag = assert(serviceBag, "No serviceBag") + self._chatProviderService = self._serviceBag:GetService(ChatProviderService) + self._chatTagBinder = self._serviceBag:GetService(ChatTag) + + self._chatTagsContainer = Instance.new("Folder") + self._chatTagsContainer.Name = HasChatTagsConstants.TAG_CONTAINER_NAME + self._chatTagsContainer.Archivable = false + self._chatTagsContainer.Parent = self._obj + self._maid:GiveTask(self._chatTagsContainer) + + self._maid:GiveTask(self:ObserveLastChatTags():Subscribe(function(tagDataList) + -- Legacy chat needs this... + self._chatProviderService:PromiseSetSpeakerTags(self._obj.Name, tagDataList or {}) + end)) + + return self +end + +function HasChatTags:GetChatTagBinder() + return self._chatTagBinder +end + +--[=[ + Adds chat tags to the player + + @param chatTagData ChatTagData +]=] +function HasChatTags:AddChatTag(chatTagData) + assert(ChatTagDataUtils.isChatTagData(chatTagData), "Bad chatTagData") + + local tag = self._chatTagBinder:Create("Folder") + tag.Name = string.format("ChatTag_%s", String.toCamelCase(chatTagData.TagText)) + tag:SetAttribute(ChatTagConstants.TAG_TEXT_ATTRIBUTE, chatTagData.TagText) + tag:SetAttribute(ChatTagConstants.TAG_COLOR_ATTRIBUTE, chatTagData.TagColor) + tag:SetAttribute(ChatTagConstants.TAG_PRIORITY_ATTRIBUTE, chatTagData.TagPriority) + + if chatTagData.TagLocalizedText then + tag:SetAttribute(ChatTagConstants.TAG_LOCALIZED_TEXT_ATTRIBUTE, LocalizedTextUtils.toJSON(chatTagData.TagLocalizedText)) + end + + tag.Parent = self._chatTagsContainer + + return tag +end + +--[=[ + Removes all chat tags from the player +]=] +function HasChatTags:ClearTags() + for _, item in pairs(self._chatTagsContainer:GetChildren()) do + if self._chatTagBinder:Get(item) then + item:Destroy() + end + end +end + +return PlayerBinder.new("HasChatTags", HasChatTags) \ No newline at end of file diff --git a/src/chatproviderservice/src/Server/ChatProviderService.lua b/src/chatproviderservice/src/Server/ChatProviderService.lua index e494918364..696fe44670 100644 --- a/src/chatproviderservice/src/Server/ChatProviderService.lua +++ b/src/chatproviderservice/src/Server/ChatProviderService.lua @@ -3,29 +3,180 @@ @class ChatProviderService ]=] -local ServerScriptService = game:GetService("ServerScriptService") - local require = require(script.Parent.loader).load(script) -local Promise = require("Promise") +local ServerScriptService = game:GetService("ServerScriptService") + +local ChatTagDataUtils = require("ChatTagDataUtils") +local LocalizedTextUtils = require("LocalizedTextUtils") local Maid = require("Maid") +local PermissionLevel = require("PermissionLevel") +local Promise = require("Promise") +local Rx = require("Rx") +local RxBrioUtils = require("RxBrioUtils") local ChatProviderService = {} ChatProviderService.ServiceName = "ChatProviderService" +function ChatProviderService:Init(serviceBag) + self._serviceBag = assert(serviceBag, "No serviceBag") + self._maid = Maid.new() + + -- External + self._serviceBag:GetService(require("CmdrService")) + self._serviceBag:GetService(require("PermissionService")) + + -- Internal + self._serviceBag:GetService(require("ChatProviderCommandService")) + + -- Binders + self._serviceBag:GetService(require("ChatTag")) + self._hasChatTagsBinder = self._serviceBag:GetService(require("HasChatTags")) + + self:SetDeveloperTag(ChatTagDataUtils.createChatTagData({ + TagText = "(dev)"; + LocalizedText = LocalizedTextUtils.create("chatTags.dev"); + TagPriority = 15; + TagColor = Color3.fromRGB(245, 163, 27); + })) + self:SetAdminTag(ChatTagDataUtils.createChatTagData({ + TagText = "(mod)"; + LocalizedText = LocalizedTextUtils.create("chatTags.mod"); + TagPriority = 10; + TagColor = Color3.fromRGB(78, 205, 196); + })) +end + + +--[=[ + Sets the developer chat tag + + @param chatTagData ChatTagData | nil + @return Maid +]=] +function ChatProviderService:SetDeveloperTag(chatTagData) + assert(ChatTagDataUtils.isChatTagData(chatTagData) or chatTagData == nil, "Bad chatTagData") + + if chatTagData then + local permissionService = self._serviceBag:GetService(require("PermissionService")) + local observeBrio = permissionService:ObservePermissionedPlayersBrio(PermissionLevel.CREATOR) + + self._maid._developer = self:_addObservablePlayerTag(observeBrio, chatTagData) + else + self._maid._developer = nil + end +end + +--[=[ + Sets the admin tag to the game + + @param chatTagData ChatTagData | nil + @return Maid +]=] +function ChatProviderService:SetAdminTag(chatTagData) + assert(ChatTagDataUtils.isChatTagData(chatTagData) or chatTagData == nil, "Bad chatTagData") + + if chatTagData then + local permissionService = self._serviceBag:GetService(require("PermissionService")) + local observeBrio = permissionService:ObservePermissionedPlayersBrio(PermissionLevel.ADMIN):Pipe({ + RxBrioUtils.flatMapBrio(function(player) + return Rx.fromPromise(permissionService:PromiseIsPermissionLevel(player, PermissionLevel.CREATOR)) + :Pipe({ + Rx.switchMap(function(isAlsoCreator) + if not isAlsoCreator then + return Rx.of(player) + else + return Rx.EMPTY + end + end) + }) + end) + }) + + self._maid._admin = self:_addObservablePlayerTag(observeBrio, chatTagData) + else + self._maid._admin = nil + end +end + +function ChatProviderService:_addObservablePlayerTag(observePlayersBrio, chatTagData) + assert(ChatTagDataUtils.isChatTagData(chatTagData), "Bad chatTagData") + + local topMaid = Maid.new() + self._maid[topMaid] = topMaid + topMaid:GiveTask(function() + self._maid[topMaid] = nil + end) + + topMaid:GiveTask(observePlayersBrio:Subscribe(function(brio) + if brio:IsDead() then + return + end + + local maid = brio:ToMaid() + local player = brio:GetValue() + + maid:GivePromise(self:PromiseAddChatTag(player, chatTagData)) + :Then(function(chatTag) + maid:GiveTask(chatTag) + end) + end)) + + return topMaid +end + +--[=[ + Promises to add a chat tag to the player + + @param player Player + @param chatTagData ChatTagData + @return Promise +]=] +function ChatProviderService:PromiseAddChatTag(player, chatTagData) + assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + assert(ChatTagDataUtils.isChatTagData(chatTagData), "Bad chatTagData") + + local hasChatTagBinder = self._serviceBag:GetService(require("HasChatTags")) + + return hasChatTagBinder:Promise(player) + :Then(function(hasChatTag) + return hasChatTag:AddChatTag(chatTagData) + end) +end + +--[=[ + Clears the player's chat chatTagData. + + @param player Player +]=] +function ChatProviderService:ClearChatTags(player) + assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + + local hasChatTagBinder = self._serviceBag:GetService(require("HasChatTags")) + local hasChatTags = hasChatTagBinder:Get(player) + + if hasChatTags then + hasChatTags:ClearTags() + end +end + --[=[ Sets the speaker's tag (by speaker name) @param speakerName string - @param tags { ChatTagData } + @param chatTagDataList { ChatTagData } ]=] -function ChatProviderService:PromiseSetSpeakerTags(speakerName, tags) +function ChatProviderService:PromiseSetSpeakerTags(speakerName, chatTagDataList) assert(type(speakerName) == "string", "Bad speakerName") - assert(type(tags) == "table", "Bad tags") + assert(ChatTagDataUtils.isChatTagDataList(chatTagDataList)) return self:_promiseSpeaker(speakerName) :Then(function(speaker) - speaker:SetExtraData("Tags", tags) + if not speaker then + return nil + end + + speaker:SetExtraData("Tags", chatTagDataList) end, function(err) warn("[ChatProviderService.PromiseSetTags] - No speaker found", err) end) @@ -36,7 +187,13 @@ function ChatProviderService:_getChatServiceAsync() return self._chatService end - local chatService = require(ServerScriptService:WaitForChild("ChatServiceRunner"):WaitForChild("ChatService")) + local chatServiceRunner = ServerScriptService:WaitForChild("ChatServiceRunner", 5) + if not chatServiceRunner then + -- Presumably we have upgraded to the new chat. + return nil + end + + local chatService = require(chatServiceRunner:WaitForChild("ChatService")) self._chatService = chatService or error("No chatService retrieved") return self._chatService @@ -63,6 +220,10 @@ function ChatProviderService:_promiseSpeaker(speakerName) assert(type(speakerName) == "string", "Bad speakerName") return self:_promiseChatService():Then(function(chatService) + if not chatService then + return nil + end + local foundSpeaker = chatService:GetSpeaker(speakerName) if foundSpeaker then return foundSpeaker @@ -71,12 +232,17 @@ function ChatProviderService:_promiseSpeaker(speakerName) local promise = Promise.new() local maid = Maid.new() + -- TODO: Avoid memory leaking + maid:GiveTask(task.delay(5, function() + warn("[ChatProviderService._promiseSpeaker] - Infinite yield possible for speaker") + end)) + -- Listen to speaker added maid:GiveTask(chatService.SpeakerAdded:Connect(function(speakerAddedName) if speakerName == speakerAddedName then local speaker = chatService:GetSpeaker(speakerName) if not speaker then - warn("[ChatProviderService] - Speaker added, but no speaker added") + warn("[ChatProviderService._promiseSpeaker] - Speaker added, but no speaker added") promise:Reject("Speaker added, but no speaker added") else promise:Resolve(speaker) diff --git a/src/chatproviderservice/src/Server/Commands/ChatProviderCommandService.lua b/src/chatproviderservice/src/Server/Commands/ChatProviderCommandService.lua new file mode 100644 index 0000000000..9c1f962b11 --- /dev/null +++ b/src/chatproviderservice/src/Server/Commands/ChatProviderCommandService.lua @@ -0,0 +1,89 @@ +--[=[ + @class ChatProviderCommandService +]=] + +local require = require(script.Parent.loader).load(script) + +local PlayerUtils = require("PlayerUtils") +local ChatTagDataUtils = require("ChatTagDataUtils") + +local ChatProviderCommandService = {} +ChatProviderCommandService.ServiceName = "ChatProviderCommandService" + +function ChatProviderCommandService:Init(serviceBag) + assert(not self._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + + -- External + self._cmdrService = self._serviceBag:GetService(require("CmdrService")) + + -- Internal + self._chatProviderService = self._serviceBag:GetService(require("ChatProviderService")) +end + +function ChatProviderCommandService:Start() + self:_registerCommands() +end + +function ChatProviderCommandService:_registerCommands() + self._cmdrService:RegisterCommand({ + Name = "add-chat-tag"; + Aliases = { }; + Description = "Adds a tag to a player"; + Group = "ChatTags"; + Args = { + { + Name = "Target"; + Type = "player"; + Description = "Player to add a tag for"; + }, + { + Name = "TagText"; + Type = "string"; + Description = "Text for the tag to have"; + }, + { + Name = "TagColor"; + Type = "color3"; + Description = "Color for the tag to have"; + Optional = true; + Default = Color3.fromRGB(255, 170, 0); + }, + { + Name = "TagPriority"; + Type = "number"; + Description = "Priority for the tag to have"; + Optional = true; + Default = 0; + }, + }; + }, function(_context, player, tagText, tagColor, priority) + self._chatProviderService:PromiseAddChatTag(player, ChatTagDataUtils.createChatTagData({ + TagText = tagText; + TagPriority = priority or 0; + TagColor = tagColor or Color3.fromRGB(255, 170, 0); + })) + + return string.format("Added tag %q to player %q", tagText, PlayerUtils.formatName(player)) + end) + + self._cmdrService:RegisterCommand({ + Name = "clear-chat-tags"; + Aliases = { }; + Description = "Clears chat tags on a player"; + Group = "ChatTags"; + Args = { + { + Name = "Target"; + Type = "player"; + Description = "Player to add a tag for"; + } + }; + }, function(_context, player) + self._chatProviderService:ClearChatTags(player) + + return string.format("Cleared chat tags on a player %q", PlayerUtils.formatName(player)) + end) +end + +return ChatProviderCommandService \ No newline at end of file diff --git a/src/chatproviderservice/src/Shared/Binders/ChatTagBase.lua b/src/chatproviderservice/src/Shared/Binders/ChatTagBase.lua new file mode 100644 index 0000000000..8741588266 --- /dev/null +++ b/src/chatproviderservice/src/Shared/Binders/ChatTagBase.lua @@ -0,0 +1,45 @@ +--[=[ + @class ChatTagBase +]=] + +local require = require(script.Parent.loader).load(script) + +local AttributeValue = require("AttributeValue") +local BaseObject = require("BaseObject") +local ChatTagConstants = require("ChatTagConstants") +local LocalizedTextUtils = require("LocalizedTextUtils") +local Rx = require("Rx") + +local ChatTagBase = setmetatable({}, BaseObject) +ChatTagBase.ClassName = "ChatTagBase" +ChatTagBase.__index = ChatTagBase + +function ChatTagBase.new(obj) + local self = setmetatable(BaseObject.new(obj), ChatTagBase) + + self._chatTagText = AttributeValue.new(self._obj, ChatTagConstants.TAG_TEXT_ATTRIBUTE, "") + self._chatTagLocalizedTextData = AttributeValue.new(self._obj, ChatTagConstants.TAG_LOCALIZED_TEXT_ATTRIBUTE, nil) + self._chatTagColor = AttributeValue.new(self._obj, ChatTagConstants.TAG_COLOR_ATTRIBUTE, Color3.new(1, 1, 1)) + self._chatTagPriority = AttributeValue.new(self._obj, ChatTagConstants.TAG_PRIORITY_ATTRIBUTE, 0) + + return self +end + +function ChatTagBase:ObserveChatTagData() + return Rx.combineLatest({ + TagText = self._chatTagText:Observe(); + TagLocalizedText = self._chatTagLocalizedTextData:Observe():Pipe({ + Rx.map(function(text) + if type(text) == "string" then + return LocalizedTextUtils.fromJSON(text) + else + return nil + end; + end); + }); + TagColor = self._chatTagColor:Observe(); + TagPriority = self._chatTagPriority:Observe(); + }) +end + +return ChatTagBase \ No newline at end of file diff --git a/src/chatproviderservice/src/Shared/Binders/HasChatTagsBase.lua b/src/chatproviderservice/src/Shared/Binders/HasChatTagsBase.lua new file mode 100644 index 0000000000..4b86e46e62 --- /dev/null +++ b/src/chatproviderservice/src/Shared/Binders/HasChatTagsBase.lua @@ -0,0 +1,78 @@ +--[=[ + @class HasChatTagsBase +]=] + +local require = require(script.Parent.loader).load(script) + +local BaseObject = require("BaseObject") +local RxBinderUtils = require("RxBinderUtils") +local RxBrioUtils = require("RxBrioUtils") +local RxInstanceUtils = require("RxInstanceUtils") +local ValueObject = require("ValueObject") +local HasChatTagsConstants = require("HasChatTagsConstants") + +local HasChatTagsBase = setmetatable({}, BaseObject) +HasChatTagsBase.ClassName = "HasChatTagsBase" +HasChatTagsBase.__index = HasChatTagsBase + +function HasChatTagsBase.new(player) + local self = setmetatable(BaseObject.new(player), HasChatTagsBase) + + self._lastChatTags = ValueObject.new(nil) + self._maid:GiveTask(self._lastChatTags) + + self._maid:GiveTask(task.defer(function() + self._maid:GiveTask(self:_observeTagDataListBrio():Subscribe(function(brio) + if brio:IsDead() then + return + end + + local tagDataList = brio:GetValue() + local maid = brio:ToMaid() + + table.sort(tagDataList, function(a, b) + return a.TagPriority > b.TagPriority + end) + + if #tagDataList > 0 then + self._lastChatTags.Value = tagDataList + + maid:GiveTask(function() + if self._lastChatTags.Value == tagDataList then + self._lastChatTags.Value = nil + end + end) + end + end)) + end)) + + return self +end + +function HasChatTagsBase:GetLastChatTags() + return self._lastChatTags.Value +end + +function HasChatTagsBase:ObserveLastChatTags() + return self._lastChatTags:Observe() +end + +function HasChatTagsBase:GetChatTagBinder() + error("Not implemented") +end + +function HasChatTagsBase:_observeTagDataListBrio() + local chatTagBinder = self:GetChatTagBinder() + + return RxInstanceUtils.observeLastNamedChildBrio(self._obj, "Folder", HasChatTagsConstants.TAG_CONTAINER_NAME):Pipe({ + RxBrioUtils.switchMapBrio(function(child) + return RxBinderUtils.observeChildrenBrio(chatTagBinder, child); + end); + RxBrioUtils.switchMapBrio(function(chatTag) + return chatTag:ObserveChatTagData() + end); + RxBrioUtils.reduceToAliveList() + }) +end + +return HasChatTagsBase \ No newline at end of file diff --git a/src/chatproviderservice/src/Shared/ChatProviderTags.rbxmx b/src/chatproviderservice/src/Shared/ChatProviderTags.rbxmx new file mode 100644 index 0000000000..2f69de0134 --- /dev/null +++ b/src/chatproviderservice/src/Shared/ChatProviderTags.rbxmx @@ -0,0 +1,33 @@ + + true + null + nil + + + + TagList + -1 + VGFnRWRpdG9yVGFnQ29udGFpbmVy + + + + + HasChatTags + -1 + + + + + + + ChatTag + -1 + + + + + \ No newline at end of file diff --git a/src/chatproviderservice/src/Shared/ChatTagConstants.lua b/src/chatproviderservice/src/Shared/ChatTagConstants.lua new file mode 100644 index 0000000000..62558a73b8 --- /dev/null +++ b/src/chatproviderservice/src/Shared/ChatTagConstants.lua @@ -0,0 +1,14 @@ +--[=[ + @class ChatTagConstants +]=] + +local require = require(script.Parent.loader).load(script) + +local Table = require("Table") + +return Table.readonly({ + TAG_TEXT_ATTRIBUTE = "TagText"; + TAG_COLOR_ATTRIBUTE = "TagColor"; + TAG_LOCALIZED_TEXT_ATTRIBUTE = "TagLocalizedText"; + TAG_PRIORITY_ATTRIBUTE = "TagPriority"; +}) \ No newline at end of file diff --git a/src/chatproviderservice/src/Shared/Data/ChatTagDataUtils.lua b/src/chatproviderservice/src/Shared/Data/ChatTagDataUtils.lua new file mode 100644 index 0000000000..b26ca688b2 --- /dev/null +++ b/src/chatproviderservice/src/Shared/Data/ChatTagDataUtils.lua @@ -0,0 +1,58 @@ +--[=[ + @class ChatTagDataUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local LocalizedTextUtils = require("LocalizedTextUtils") + +local ChatTagDataUtils = {} + +--[=[ + Creates new chat tag data + + @param data ChatTagData + @return ChatTagData +]=] +function ChatTagDataUtils.createChatTagData(data) + assert(ChatTagDataUtils.isChatTagData(data), "Bad data") + + return data +end + +--[=[ + Returns true if a valid list + + @param data any + @return boolean + @return string -- reason why +]=] +function ChatTagDataUtils.isChatTagDataList(data) + if type(data) ~= "table" then + return false, "not a table" + end + + for _, item in pairs(data) do + if not ChatTagDataUtils.isChatTagData(item) then + return false, "Bad tag data" + end + end + + return true +end + +--[=[ + Returns if chat tag data + + @param data any + @return boolean +]=] +function ChatTagDataUtils.isChatTagData(data) + return type(data) == "table" + and type(data.TagText) == "string" + and type(data.TagPriority) == "number" + and (LocalizedTextUtils.isLocalizedText(data.TagLocalizedText) or data.TagLocalizedText == nil) + and typeof(data.TagColor) == "Color3" +end + +return ChatTagDataUtils \ No newline at end of file diff --git a/src/chatproviderservice/src/Shared/HasChatTagsConstants.lua b/src/chatproviderservice/src/Shared/HasChatTagsConstants.lua new file mode 100644 index 0000000000..86d54a70d1 --- /dev/null +++ b/src/chatproviderservice/src/Shared/HasChatTagsConstants.lua @@ -0,0 +1,11 @@ +--[=[ + @class HasChatTagsConstants +]=] + +local require = require(script.Parent.loader).load(script) + +local Table = require("Table") + +return Table.readonly({ + TAG_CONTAINER_NAME = "ChatProviderService_ChatTags"; +}) \ No newline at end of file diff --git a/src/chatproviderservice/test/default.project.json b/src/chatproviderservice/test/default.project.json new file mode 100644 index 0000000000..6c091c6eca --- /dev/null +++ b/src/chatproviderservice/test/default.project.json @@ -0,0 +1,21 @@ +{ + "name": "ChatProviderServiceTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "chatproviderservice": { + "$path": ".." + }, + "Script": { + "$path": "scripts/Server" + } + }, + "StarterPlayer": { + "StarterPlayerScripts": { + "Main": { + "$path": "scripts/Client" + } + } + } + } +} \ No newline at end of file diff --git a/src/chatproviderservice/test/scripts/Client/ClientMain.client.lua b/src/chatproviderservice/test/scripts/Client/ClientMain.client.lua new file mode 100644 index 0000000000..5ae2be9941 --- /dev/null +++ b/src/chatproviderservice/test/scripts/Client/ClientMain.client.lua @@ -0,0 +1,11 @@ +--[[ + @class ClientMain +]] +local packages = game:GetService("ReplicatedStorage"):WaitForChild("Packages") + +local serviceBag = require(packages.ServiceBag).new() +serviceBag:GetService(packages.ChatProviderServiceClient) + +-- Start game +serviceBag:Init() +serviceBag:Start() \ No newline at end of file diff --git a/src/chatproviderservice/test/scripts/Server/ServerMain.server.lua b/src/chatproviderservice/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 0000000000..a01efb0eec --- /dev/null +++ b/src/chatproviderservice/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,14 @@ +--[[ + @class ServerMain +]] +local ServerScriptService = game:GetService("ServerScriptService") + +local loader = ServerScriptService:FindFirstChild("LoaderUtils", true).Parent +local packages = require(loader).bootstrapGame(ServerScriptService.chatproviderservice) + +local serviceBag = require(packages.ServiceBag).new() +serviceBag:GetService(packages.ChatProviderService) + +-- Start game +serviceBag:Init() +serviceBag:Start() \ No newline at end of file diff --git a/src/color3utils/src/Shared/Color3Utils.lua b/src/color3utils/src/Shared/Color3Utils.lua index 2cdf9b211f..d34557b0e7 100644 --- a/src/color3utils/src/Shared/Color3Utils.lua +++ b/src/color3utils/src/Shared/Color3Utils.lua @@ -123,7 +123,42 @@ end @return number ]=] function Color3Utils.toHexInteger(color3) + assert(typeof(color3) == "Color3", "Bad color3") + return bit32.bor(bit32.lshift(color3.r*0xFF, 16), bit32.lshift(color3.g*0xFF, 8), color3.b*0xFF) end +--[=[ + Converts the color3 to the actual hex integer used in web and other + areas. + + ``` + Color3Utils.toHexString(Color3.fromRGB(0, 255, 0)) --> 00FF00 + ``` + + @param color3 Color3 + @return number +]=] +function Color3Utils.toHexString(color3) + assert(typeof(color3) == "Color3", "Bad color3") + + return string.format("%06X", Color3Utils.toHexInteger(color3)) +end + +--[=[ + Converts the color3 to the standard web hex string + + ``` + Color3Utils.toWebHexString(Color3.fromRGB(0, 255, 0)) --> #00FF00 + ``` + + @param color3 Color3 + @return number +]=] +function Color3Utils.toWebHexString(color3) + assert(typeof(color3) == "Color3", "Bad color3") + + return string.format("#%06X", Color3Utils.toHexInteger(color3)) +end + return Color3Utils \ No newline at end of file diff --git a/src/colorpalette/src/Shared/Grade/ColorGradePalette.lua b/src/colorpalette/src/Shared/Grade/ColorGradePalette.lua index 5a44f869a9..94febc8ef2 100644 --- a/src/colorpalette/src/Shared/Grade/ColorGradePalette.lua +++ b/src/colorpalette/src/Shared/Grade/ColorGradePalette.lua @@ -85,7 +85,6 @@ function ColorGradePalette:GetVividness(gradeName) assert(type(vividness) == "number", "Bad vividness retrieved") return vividness - end function ColorGradePalette:Add(gradeName, colorGrade, vividness) diff --git a/src/colorpalette/src/Shared/Grade/ColorGradeUtils.lua b/src/colorpalette/src/Shared/Grade/ColorGradeUtils.lua index 46f14ab7c1..2b45e2d341 100644 --- a/src/colorpalette/src/Shared/Grade/ColorGradeUtils.lua +++ b/src/colorpalette/src/Shared/Grade/ColorGradeUtils.lua @@ -42,6 +42,11 @@ function ColorGradeUtils.addGrade(grade, difference) return finalGrade end +function ColorGradeUtils.addGradeToColor(color, difference) + local grade = ColorGradeUtils.getGrade(color) + return ColorGradeUtils.getGradedColor(color, ColorGradeUtils.addGrade(grade, difference)) +end + --[=[ Ensures the given contrast between the color and the backing, with an adjustment to saturation to keep the UI loking good @@ -65,7 +70,8 @@ function ColorGradeUtils.ensureGradeContrast(color, backing, amount) return color end - local newRel = math.sign(rel)*amount + local direction = math.sign(rel) > 0 and 1 or -1 + local newRel = direction*amount local newGrade = math.clamp(backingGrade + newRel, 0, 100) local otherNewGrade = math.clamp(backingGrade - newRel, 0, 100) diff --git a/src/datastore/README.md b/src/datastore/README.md index 0235544be7..ed0b6ea185 100644 --- a/src/datastore/README.md +++ b/src/datastore/README.md @@ -15,6 +15,17 @@ This system is a reliable datastore system designed with promises and asyncronio
View docs →
+## Executive overiew +This datastore prevents data loss by being explicit about what we're writing to, and only modifying the data that exists there instead of modifying the whole structure. + +## How syncing works +Sometimes datastores (like a global game data store) need to be synced live instead of upon server or player start. This is if we expect multiple servers to write to the same datastore at once we can use thie sync method to + +Syncing is like saving. However, instead of treating the current datastore as a session lock, we load in additional data from our "source-of-truth". From here, we merge that data into the datastore, which means both clearing any matching write tokens that our sync says is done. + +This is best for a "shared" memory that can be temporarily not correct. Deleting with a sync is less effective. + + ## Installation ``` npm install @quenty/datastore --save diff --git a/src/datastore/package.json b/src/datastore/package.json index be2f6b8c54..3f0686b76d 100644 --- a/src/datastore/package.json +++ b/src/datastore/package.json @@ -30,11 +30,14 @@ "@quenty/bindtocloseservice": "file:../bindtocloseservice", "@quenty/loader": "file:../loader", "@quenty/maid": "file:../maid", + "@quenty/math": "file:../math", "@quenty/promise": "file:../promise", "@quenty/rx": "file:../rx", + "@quenty/servicebag": "file:../servicebag", "@quenty/signal": "file:../signal", "@quenty/symbol": "file:../symbol", - "@quenty/table": "file:../table" + "@quenty/table": "file:../table", + "@quenty/valueobject": "file:../valueobject" }, "publishConfig": { "access": "public" diff --git a/src/datastore/src/Server/DataStore.lua b/src/datastore/src/Server/DataStore.lua index fb3a08490f..6326a0c315 100644 --- a/src/datastore/src/Server/DataStore.lua +++ b/src/datastore/src/Server/DataStore.lua @@ -71,13 +71,14 @@ local DataStoreStage = require("DataStoreStage") local Maid = require("Maid") local Promise = require("Promise") local Signal = require("Signal") +local Math = require("Math") +local ValueObject = require("ValueObject") +local Rx = require("Rx") -local DEBUG_WRITING = false +local DEFAULT_DEBUG_WRITING = false -local AUTO_SAVE_TIME = 60*5 -local CHECK_DIVISION = 15 -local JITTER = 20 -- Randomly assign jitter so if a ton of players join at once we don't hit the datastore at once -local DEFAULT_CACHE_TIME_SECONDS = math.huge +local DEFAULT_AUTO_SAVE_TIME_SECONDS = 60*5 +local DEFAULT_JITTER_PROPORTION = 0.1 -- Randomly assign jitter so if a ton of players join at once we don't hit the datastore at once local DataStore = setmetatable({}, DataStoreStage) DataStore.ClassName = "DataStore" @@ -85,16 +86,35 @@ DataStore.__index = DataStore --[=[ Constructs a new DataStore. See [DataStoreStage] for more API. + + ```lua + local dataStore = serviceBag:GetService(PlayerDataStoreService):PromiseDataStore(player):Yield() + ``` + @param robloxDataStore DataStore @param key string @return DataStore ]=] function DataStore.new(robloxDataStore, key) - local self = setmetatable(DataStoreStage.new(), DataStore) + local self = setmetatable(DataStoreStage.new(key), DataStore) self._key = key or error("No key") self._robloxDataStore = robloxDataStore or error("No robloxDataStore") - self._cacheTimeSeconds = DEFAULT_CACHE_TIME_SECONDS + self._debugWriting = DEFAULT_DEBUG_WRITING + + self._autoSaveTimeSeconds = ValueObject.new(DEFAULT_AUTO_SAVE_TIME_SECONDS) + self._maid:GiveTask(self._autoSaveTimeSeconds) + + self._jitterProportion = ValueObject.new(DEFAULT_JITTER_PROPORTION, "number") + self._maid:GiveTask(self._jitterProportion) + + self._syncOnSave = ValueObject.new(false, "boolean") + self._maid:GiveTask(self._syncOnSave) + + self._loadedOk = ValueObject.new(false, "boolean") + self._maid:GiveTask(self._loadedOk) + + self._userIdList = nil if self._key == "" then error("[DataStore] - Key cannot be an empty string") @@ -108,41 +128,20 @@ function DataStore.new(robloxDataStore, key) self.Saving = Signal.new() -- :Fire(promise) self._maid:GiveTask(self.Saving) - task.spawn(function() - while self.Destroy do - for _=1, CHECK_DIVISION do - task.wait(AUTO_SAVE_TIME/CHECK_DIVISION) - if not self.Destroy then - break - end - end - - if not self.Destroy then - break - end - - -- Apply additional jitter on auto-save - task.wait(math.random(1, JITTER)) - - if not self.Destroy then - break - end - - self:Save() - end - end) + self:_setupAutoSaving() return self end --[=[ - Sets how long the datastore will cache for - @param cacheTimeSeconds number? + Set to true to debug writing this data store + + @param debugWriting boolean ]=] -function DataStore:SetCacheTime(cacheTimeSeconds) - assert(type(cacheTimeSeconds) == "number" or cacheTimeSeconds == nil, "Bad cacheTimeSeconds") +function DataStore:SetDoDebugWriting(debugWriting) + assert(type(debugWriting) == "boolean", "Bad debugWriting") - self._cacheTimeSeconds = cacheTimeSeconds or DEFAULT_CACHE_TIME_SECONDS + self._debugWriting = debugWriting end --[=[ @@ -153,16 +152,39 @@ function DataStore:GetFullPath() return ("RobloxDataStore@%s"):format(self._key) end +--[=[ + How frequent the data store will autosave (or sync) to the cloud. If set to nil then the datastore + will not do any syncing. + + @param autoSaveTimeSeconds number | nil +]=] +function DataStore:SetAutoSaveTimeSeconds(autoSaveTimeSeconds) + assert(type(autoSaveTimeSeconds) == "number" or autoSaveTimeSeconds == nil, "Bad autoSaveTimeSeconds") + + self._autoSaveTimeSeconds.Value = autoSaveTimeSeconds +end + +--[=[ + How frequent the data store will autosave (or sync) to the cloud + + @param syncEnabled boolean +]=] +function DataStore:SetSyncOnSave(syncEnabled) + assert(type(syncEnabled) == "boolean", "Bad syncEnabled") + + self._syncOnSave.Value = syncEnabled +end + --[=[ Returns whether the datastore failed. @return boolean ]=] function DataStore:DidLoadFail() - if not self._loadPromise then + if not self._firstLoadPromise then return false end - if self._loadPromise:IsRejected() then + if self._firstLoadPromise:IsRejected() then return true end @@ -175,7 +197,7 @@ end @return Promise ]=] function DataStore:PromiseLoadSuccessful() - return self._maid:GivePromise(self:_promiseLoad()):Then(function() + return self._maid:GivePromise(self:PromiseViewUpToDate()):Then(function() return true end, function() return false @@ -187,67 +209,208 @@ end @return Promise ]=] function DataStore:Save() - if self:DidLoadFail() then - warn("[DataStore] - Not saving, failed to load") - return Promise.rejected("Load not successful, not saving") + return self:_syncData(false) +end + +--[=[ + Same as saving the data but it also loads fresh data from the datastore, which may consume + additional data-store query calls. + + @return Promise +]=] +function DataStore:Sync() + return self:_syncData(true) +end + +--[=[ + Sets the user id list associated with this datastore. Can be useful for GDPR compliance. + + @param userIdList { number } | nil +]=] +function DataStore:SetUserIdList(userIdList) + assert(type(userIdList) == "table" or userIdList == nil, "Bad userIdList") + + self._userIdList = userIdList +end + +--[=[ + Returns a list of user ids or nil + + @return { number } | nil +]=] +function DataStore:GetUserIdList() + return self._userIdList +end + +--[=[ + Overridden helper method for data store stage below. + + @return Promise +]=] +function DataStore:PromiseViewUpToDate() + if self._firstLoadPromise then + return self._firstLoadPromise end - if DEBUG_WRITING then - print("[DataStore.Save] - Starting save routine") + self._firstLoadPromise = self:_promiseGetAsyncNoCache() + + self._firstLoadPromise:Tap(function() + self._loadedOk.Value = true + end) + + return self._firstLoadPromise +end + +function DataStore:_setupAutoSaving() + local startTime = os.clock() + + self._maid:GiveTask(Rx.combineLatest({ + autoSaveTimeSeconds = self._autoSaveTimeSeconds:Observe(); + jitterProportion = self._jitterProportion:Observe(); + syncOnSave = self._syncOnSave:Observe(); + loadedOk = self._loadedOk:Observe(); + }):Subscribe(function(state) + if state.autoSaveTimeSeconds and state.loadedOk then + local maid = Maid.new() + if self._debugWriting then + print("Auto-saving loop started") + end + + -- TODO: First jitter is way noisier to differentiate servers + maid:GiveTask(task.spawn(function() + while true do + local jitterBase = math.random() + local timeElapsed = os.clock() - startTime + local totalWaitTime = Math.jitter(state.autoSaveTimeSeconds, state.jitterProportion*state.autoSaveTimeSeconds, jitterBase) + local timeRemaining = totalWaitTime - timeElapsed + + if timeRemaining > 0 then + task.wait(timeRemaining) + end + + startTime = os.clock() + + if state.syncOnSave then + self:Sync() + else + self:Save() + end + + task.wait(0.1) + end + end)) + + self._maid._autoSavingMaid = maid + else + self._maid._autoSavingMaid = nil + end + end)) +end + +function DataStore:_syncData(doMergeNewData) + if self:DidLoadFail() then + warn("[DataStore] - Not syncing, failed to load") + return Promise.rejected("Load not successful, not syncing") end - -- Avoid constructing promises for every callback down the datastore - -- upon save. - return (self:_promiseInvokeSavingCallbacks() or Promise.resolved()) + return self._maid:GivePromise(self:PromiseViewUpToDate()) + :Then(function() + return self._maid:GivePromise(self:PromiseInvokeSavingCallbacks()) + end) :Then(function() if not self:HasWritableData() then + if doMergeNewData then + -- Reads are cheaper than update async calls + return self:_promiseGetAsyncNoCache() + end + -- Nothing to save, don't update anything - if DEBUG_WRITING then - print("[DataStore.Save] - Not saving, nothing staged") + if self._debugWriting then + print("[DataStore] - Not saving, nothing staged") end + return nil else - return self:_saveData(self:GetNewWriter()) + return self:_doDataSync(self:GetNewWriter(), doMergeNewData) end end) end ---[=[ - Loads data. This returns the originally loaded data. - @param keyName string - @param defaultValue any? - @return any? -]=] -function DataStore:Load(keyName, defaultValue) - return self:_promiseLoad() - :Then(function(data) - return self:_afterLoadGetAndApplyStagedData(keyName, data, defaultValue) - end) -end +function DataStore:_doDataSync(writer, doMergeNewData) + assert(type(doMergeNewData) == "boolean", "Bad doMergeNewData") + + -- Cache user id list + writer:SetUserIdList(self:GetUserIdList()) -function DataStore:_saveData(writer) local maid = Maid.new() local promise = Promise.new() - promise:Resolve(maid:GivePromise(DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(data) - if promise:IsRejected() then - -- Cancel if we have another request - return nil + + if writer:IsCompleteWipe() then + if self._debugWriting then + print(string.format("[DataStore] - DataStorePromises.removeAsync(%q)", self._key)) end - data = writer:WriteMerge(data or {}) - assert(data ~= DataStoreDeleteToken, "Cannot delete from UpdateAsync") + -- This is, of course, dangerous, because we won't merge + promise:Resolve(maid:GivePromise(DataStorePromises.removeAsync(self._robloxDataStore, self._key)):Then(function() + if doMergeNewData then + -- Write our data + self:MarkDataAsSaved(writer) - if DEBUG_WRITING then - print("[DataStore] - Writing", game:GetService("HttpService"):JSONEncode(data)) + -- Do syncing after + return self:_promiseGetAsyncNoCache() + end + end)) + else + if self._debugWriting then + print(string.format("[DataStore] - DataStorePromises.updateAsync(%q) with doMergeNewData = %s", self._key, tostring(doMergeNewData))) end - return data - end, function(err) + promise:Resolve(maid:GivePromise(DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(original, datastoreKeyInfo) + if promise:IsRejected() then + -- Cancel if we have another request + return nil + end + + local diffSnapshot + if doMergeNewData then + diffSnapshot = writer:ComputeDiffSnapshot(original) + end + + local result = writer:WriteMerge(original) + + if result == DataStoreDeleteToken or result == nil then + result = {} + end + + if self._debugWriting then + print("[DataStore] - Writing", result) + end + + if doMergeNewData then + -- This prevents resaving at high frequency + self:MarkDataAsSaved(writer) + self:MergeDiffSnapshot(diffSnapshot) + end + + local userIdList = writer:GetUserIdList() + if datastoreKeyInfo then + userIdList = datastoreKeyInfo:GetUserIds() + end + + local metadata = nil + if datastoreKeyInfo then + metadata = datastoreKeyInfo:GetMetadata() + end + + return result, userIdList, metadata + end))) + end + + promise:Tap(nil, function(err) -- Might be caused by Maid rejecting state - warn("[DataStore] - Failed to UpdateAsync data", err) - return Promise.rejected(err) - end))) + warn("[DataStore] - Failed to sync data", err) + end) self._maid._saveMaid = maid @@ -258,27 +421,23 @@ function DataStore:_saveData(writer) return promise end -function DataStore:_promiseLoad() - if self._loadPromise then - return self._loadPromise - end - - self._loadPromise = self._maid:GivePromise(DataStorePromises.getAsync(self._robloxDataStore, self._key) - :Then(function(data) - if data == nil then - return {} - elseif type(data) == "table" then - return data - else - return Promise.rejected("Failed to load data. Wrong type '" .. type(data) .. "'") - end - end, function(err) - -- Log: - warn("[DataStore] - Failed to GetAsync data", err) +function DataStore:_promiseGetAsyncNoCache() + return self._maid:GivePromise(DataStorePromises.getAsync(self._robloxDataStore, self._key)) + :Catch(function(err) + warn(string.format("DataStorePromises.getAsync(%q) -> warning - ", self._key), err) return Promise.rejected(err) - end)) + end) + :Then(function(data) + local writer = self:GetNewWriter() + local diffSnapshot = writer:ComputeDiffSnapshot(data) - return self._loadPromise + self:MergeDiffSnapshot(diffSnapshot) + + if self._debugWriting then + print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data, "with diff snapshot", diffSnapshot, "to view", self._viewSnapshot) + -- print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data) + end + end) end return DataStore \ No newline at end of file diff --git a/src/datastore/src/Server/GameDataStoreService.lua b/src/datastore/src/Server/GameDataStoreService.lua index bc8ac021dc..030036ab0a 100644 --- a/src/datastore/src/Server/GameDataStoreService.lua +++ b/src/datastore/src/Server/GameDataStoreService.lua @@ -10,6 +10,7 @@ local require = require(script.Parent.loader).load(script) local DataStore = require("DataStore") local DataStorePromises = require("DataStorePromises") local Maid = require("Maid") +local Promise = require("Promise") local GameDataStoreService = {} GameDataStoreService.ServiceName = "GameDataStoreService" @@ -30,11 +31,16 @@ function GameDataStoreService:PromiseDataStore() self._dataStorePromise = self:_promiseRobloxDataStore() :Then(function(robloxDataStore) + -- Live sync this stuff pretty frequently local dataStore = DataStore.new(robloxDataStore, self:_getKey()) + dataStore:SetSyncOnSave(true) + dataStore:SetAutoSaveTimeSeconds(15) self._maid:GiveTask(dataStore) self._maid:GiveTask(self._bindToCloseService:RegisterPromiseOnCloseCallback(function() - return dataStore:Save() + return Promise.defer(function(resolve) + return resolve(dataStore:Save()) + end) end)) return dataStore diff --git a/src/datastore/src/Server/Modules/DataStoreSnapshotUtils.lua b/src/datastore/src/Server/Modules/DataStoreSnapshotUtils.lua new file mode 100644 index 0000000000..11ad83b3e2 --- /dev/null +++ b/src/datastore/src/Server/Modules/DataStoreSnapshotUtils.lua @@ -0,0 +1,13 @@ +--[=[ + @class DataStoreSnapshotUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local DataStoreSnapshotUtils = {} + +function DataStoreSnapshotUtils.isEmptySnapshot(snapshot) + return type(snapshot) == "table" and next(snapshot) == nil +end + +return DataStoreSnapshotUtils \ No newline at end of file diff --git a/src/datastore/src/Server/Modules/DataStoreStage.lua b/src/datastore/src/Server/Modules/DataStoreStage.lua index 169f84e6cc..7c78e8788b 100644 --- a/src/datastore/src/Server/Modules/DataStoreStage.lua +++ b/src/datastore/src/Server/Modules/DataStoreStage.lua @@ -4,6 +4,12 @@ at children level. This minimizes accidently overwriting. The big cost here is that we may leave keys that can't be removed. + Layers in priority order: + + 1. Save data + 2. Substores + 3. Base layer + @server @class DataStoreStage ]=] @@ -13,13 +19,17 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") local DataStoreDeleteToken = require("DataStoreDeleteToken") local DataStoreWriter = require("DataStoreWriter") +local GoodSignal = require("GoodSignal") local Maid = require("Maid") +local Observable = require("Observable") +local ObservableSubscriptionTable = require("ObservableSubscriptionTable") local Promise = require("Promise") local PromiseUtils = require("PromiseUtils") -local Signal = require("Signal") +local Set = require("Set") local Table = require("Table") -local Observable = require("Observable") -local ObservableSubscriptionTable = require("ObservableSubscriptionTable") +local DataStoreSnapshotUtils = require("DataStoreSnapshotUtils") + +local SLOW_INTEGRITY_CHECK_ENABLED = true local DataStoreStage = setmetatable({}, BaseObject) DataStoreStage.ClassName = "DataStoreStage" @@ -28,6 +38,14 @@ DataStoreStage.__index = DataStoreStage --[=[ Constructs a new DataStoreStage to load from. Prefer to use DataStore because this doesn't have any way to retrieve this. + + See [DataStore], [GameDataStoreService], and [PlayerDataStoreService]. + + ```lua + -- Data store inherits from DataStoreStage + local dataStore = serviceBag:GetService(PlayerDataStoreService):PromiseDataStore(player):Yield() + ``` + @param loadName string @param loadParent DataStoreStage? @return DataStoreStage @@ -39,136 +57,218 @@ function DataStoreStage.new(loadName, loadParent) self._loadName = loadName self._loadParent = loadParent - self._savingCallbacks = {} -- [func, ...] - self._takenKeys = {} -- [name] = true + self.Changed = GoodSignal.new() -- :Fire(viewSnapshot) + self._maid:GiveTask(self.Changed) + + self.DataStored = GoodSignal.new() + self._maid:GiveTask(self.DataStored) + + -- Stores the actual data loaded and synced (but not pending written data) + self._saveDataSnapshot = nil self._stores = {} -- [name] = dataSubStore + self._baseDataSnapshot = nil - self._subsTable = ObservableSubscriptionTable.new() - self._maid:GiveTask(self._subsTable) + -- View data + self._viewSnapshot = nil + + self._savingCallbacks = {} -- [func, ...] + + self._keySubscriptions = ObservableSubscriptionTable.new() + self._maid:GiveTask(self._keySubscriptions) return self end --- Also returns nil for speedyness -function DataStoreStage:_promiseInvokeSavingCallbacks() - if not next(self._savingCallbacks) then - return nil - end +--[=[ + Stores the value, firing off events and queuing the item for save. - local removingPromises = {} - for _, func in pairs(self._savingCallbacks) do - local result = func() - if Promise.isPromise(result) then - table.insert(removingPromises, result) - end - end + ```lua + dataStore:Store("money", 25) + ``` - for _, substore in pairs(self._stores) do - local promise = substore:_promiseInvokeSavingCallbacks() - if promise then - table.insert(removingPromises, promise) - end + @param key string | number + @param value any +]=] +function DataStoreStage:Store(key, value) + assert(type(key) == "string", "Bad key") + + if value == nil then + value = DataStoreDeleteToken end - return PromiseUtils.all(removingPromises) + -- Ensure that we at least start loading (and thus the autosave loop) for write + self:PromiseViewUpToDate() + + self:_storeAtKey(key, value) end --[=[ - Adds a callback to be called before save. This may return a promise. - @param callback function -- May return a promise - @return function -- Call to remove -]=] -function DataStoreStage:AddSavingCallback(callback) - assert(type(callback) == "function", "Bad callback") + Loads the data at the `key` and returns a promise with that value - table.insert(self._savingCallbacks, callback) + ```lua + dataStore:Load():Then(function(data) + print(data) + end) + ``` - return function() - if self.Destroy then - self:RemoveSavingCallback(callback) + @param key string | number + @param defaultValue T? + @return Promise +]=] +function DataStoreStage:Load(key, defaultValue) + assert(type(key) == "string" or type(key) == "number", "Bad key") + + return self:PromiseViewUpToDate():Then(function() + if type(self._viewSnapshot) == "table" then + local value = self._viewSnapshot[key] + if value ~= nil then + return value + else + return defaultValue + end + else + return defaultValue end - end + end) end --[=[ - Removes a saving callback from the data store stage - @param callback function -]=] -function DataStoreStage:RemoveSavingCallback(callback) - assert(type(callback) == "function", "Bad callback") + Promises the full content for the datastore - local index = table.find(self._savingCallbacks, callback) - if index then - table.remove(self._savingCallbacks, index) - end + ```lua + dataStore:LoadAll():Then(function(data) + print(data) + end) + ``` + + @return Promise +]=] +function DataStoreStage:LoadAll() + return self:PromiseViewUpToDate():Then(function() + return self._viewSnapshot + end) end --[=[ - Gets an event that will fire off whenever something is stored at this level - @return Signal + Gets a sub-datastore that will write at the given key. This will have the same + helper methods as any other data store object. + + ```lua + local dataStore = DataStore.new() + + local saveslot = dataStore:GetSubStore("saveslot0") + saveslot:Store("Money", 0) + ``` + + @param key string | number + @return DataStoreStage ]=] -function DataStoreStage:GetTopLevelDataStoredSignal() - if self._topLevelStoreSignal then - return self._topLevelStoreSignal +function DataStoreStage:GetSubStore(key) + assert(type(key) == "string" or type(key) == "number", "Bad key") + + if self._stores[key] then + return self._stores[key] end - self._topLevelStoreSignal = Signal.new() - self._maid:GiveTask(self._topLevelStoreSignal) - return self._topLevelStoreSignal -end + local maid = Maid.new() + local newStore = DataStoreStage.new(key, self) + maid:GiveTask(newStore) + + if type(self._baseDataSnapshot) == "table" then + local baseDataToTransfer = self._baseDataSnapshot[key] + if baseDataToTransfer ~= nil then + local newSnapshot = table.clone(self._baseDataSnapshot) + newSnapshot[key] = nil + newStore:MergeDiffSnapshot(baseDataToTransfer) + self._baseDataSnapshot = table.freeze(newSnapshot) + end + end ---[=[ - Retrieves the full path of this datastore stage for diagnostic purposes. - @return string -]=] -function DataStoreStage:GetFullPath() - if self._loadParent then - return self._loadParent:GetFullPath() .. "." .. tostring(self._loadName) - else - return tostring(self._loadName) + -- Transfer save data to substore + if type(self._saveDataSnapshot) == "table" then + local saveDataToTransfer = self._saveDataSnapshot[key] + + if saveDataToTransfer ~= nil then + local newSnapshot = table.clone(self._saveDataSnapshot) + newSnapshot[key] = nil + + newStore:Overwrite(saveDataToTransfer) + + if DataStoreSnapshotUtils.isEmptySnapshot(newSnapshot) then + self._saveDataSnapshot = nil + else + self._saveDataSnapshot = table.freeze(newSnapshot) + end + end end + + self._stores[key] = newStore + self._maid[maid] = maid + + maid:GiveTask(newStore.Changed:Connect(function() + self:_updateViewSnapshotAtKey(key) + end)) + self:_updateViewSnapshotAtKey(key) + + return newStore end --[=[ - Loads the data at the `name`. + Explicitely deletes data at the key - @param name string | number - @param defaultValue T? - @return Promise + @param key string | number ]=] -function DataStoreStage:Load(name, defaultValue) - assert(type(name) == "string" or type(name) == "number", "Bad name") +function DataStoreStage:Delete(key) + assert(type(key) == "string", "Bad key") - if self._dataToSave and self._dataToSave[name] ~= nil then - if self._dataToSave[name] == DataStoreDeleteToken then - return Promise.resolved(defaultValue) - else - return Promise.resolved(self._dataToSave[name]) - end - end + self:_storeAtKey(key, DataStoreDeleteToken) +end - return self:_promiseLoadParentContent():Then(function(data) - return self:_afterLoadGetAndApplyStagedData(name, data, defaultValue) - end) +--[=[ + Queues up a wipe of all values. This will completely set the data to nil. +]=] +function DataStoreStage:Wipe() + self:Overwrite(DataStoreDeleteToken) end --[=[ Observes the current value for the stage itself - @param name string | number + If no key is passed than it will observe the whole view snapshot + + @param key string | number | nil @param defaultValue T? @return Observable ]=] -function DataStoreStage:Observe(name, defaultValue) - assert(type(name) == "string" or type(name) == "number", "Bad name") +function DataStoreStage:Observe(key, defaultValue) + assert(type(key) == "string" or type(key) == "number" or key == nil, "Bad key") + + if key == nil then + return Observable.new(function(sub) + local maid = Maid.new() + maid:GivePromise(self:LoadAll()) + :Then(function() + -- Only connect once loaded + maid:GiveTask(self.Changed:Connect(function(viewSnapshot) + sub:Fire(viewSnapshot) + end)) + + sub:Fire(self._viewSnapshot) + end, function(...) + sub:Fail(...) + end) + + return maid + end) + end return Observable.new(function(sub) local maid = Maid.new() - maid:GiveTask(self._subsTable:Observe(name):Subscribe(sub:GetFireFailComplete())) + maid:GiveTask(self._keySubscriptions:Observe(key):Subscribe(sub:GetFireFailComplete())) -- Load initially - maid:GivePromise(self:Load(name, defaultValue)) + maid:GivePromise(self:Load(key, defaultValue)) :Then(function(value) sub:Fire(value) end, function(...) @@ -179,61 +279,61 @@ function DataStoreStage:Observe(name, defaultValue) end) end --- Protected! -function DataStoreStage:_afterLoadGetAndApplyStagedData(name, data, defaultValue) - assert(type(name) == "string" or type(name) == "number", "Bad name") +--[=[ + Adds a callback to be called before save. This may return a promise. - if self._dataToSave and self._dataToSave[name] ~= nil then - if self._dataToSave[name] == DataStoreDeleteToken then - return defaultValue - else - return self._dataToSave[name] - end - elseif self._stores[name] then - if self._stores[name]:HasWritableData() then - local writer = self._stores[name]:GetNewWriter() - local original = Table.deepCopy(data[name] or {}) - writer:WriteMerge(original) - return original - end - end + @param callback function -- May return a promise + @return function -- Call to remove +]=] +function DataStoreStage:AddSavingCallback(callback) + assert(type(callback) == "function", "Bad callback") - if data[name] == nil then - return defaultValue - else - return data[name] + table.insert(self._savingCallbacks, callback) + + return function() + if self.Destroy then + self:RemoveSavingCallback(callback) + end end end --[=[ - Explicitely deletes data at the key - - @param name string | number + Removes a saving callback from the data store stage + @param callback function ]=] -function DataStoreStage:Delete(name) - assert(type(name) == "string", "Bad name") +function DataStoreStage:RemoveSavingCallback(callback) + assert(type(callback) == "function", "Bad callback") - if self._takenKeys[name] then - error(("[DataStoreStage] - Already have a writer for %q"):format(name)) + local index = table.find(self._savingCallbacks, callback) + if index then + table.remove(self._savingCallbacks, index) end +end + +--[=[ + Gets an event that will fire off whenever something is stored at this level - self:_doStore(name, DataStoreDeleteToken) + @return Signal +]=] +function DataStoreStage:GetTopLevelDataStoredSignal() + return self.DataStored end --[=[ - Queues up a wipe of all values. Data must load before it can be wiped. + Retrieves the full path of this datastore stage for diagnostic purposes. + + @return string ]=] -function DataStoreStage:Wipe() - return self._loadParent:Load(self._loadName, {}) - :Then(function(data) - for key, _ in pairs(data) do - if self._stores[key] then - self._stores[key]:Wipe() - else - self:_doStore(key, DataStoreDeleteToken) - end - end - end) +function DataStoreStage:GetFullPath() + if self._fullPath then + return self._fullPath + elseif self._loadParent then + self._fullPath = self._loadParent:GetFullPath() .. "." .. tostring(self._loadName) + return self._fullPath + else + self._fullPath = tostring(self._loadName) + return self._fullPath + end end --[=[ @@ -258,78 +358,108 @@ end @return Promise<{ [string]: true }> ]=] function DataStoreStage:PromiseKeySet() - return self:_promiseLoadParentContent():Then(function(data) - local keySet = {} + return self:PromiseViewUpToDate():Then(function() + return Set.fromKeys(self._viewSnapshot) + end) +end - for key, value in pairs(data) do - if value ~= DataStoreDeleteToken then - keySet[key] = true - end - end +--[=[ + This will always prioritize our own view of the world over + incoming data. - if self._dataToSave then - for key, value in pairs(self._dataToSave) do - if value ~= DataStoreDeleteToken then - keySet[key] = true - end - end - end + :::tip + This is a helper method that helps load diff data into the data store. + ::: - -- Otherwise we assume previous data would have it - for key, store in pairs(self._stores) do - if store:HasWritableData() then - keySet[key] = true - end - end + @param diffSnapshot any +]=] +function DataStoreStage:MergeDiffSnapshot(diffSnapshot) + self:_checkIntegrity() - return keySet - end) + self._baseDataSnapshot = self:_updateStoresAndComputeBaseDataSnapshotFromDiffSnapshot(diffSnapshot) + + self:_updateViewSnapshot() + self:_checkIntegrity() end --[=[ - Promises the full content for the datastore + Updates the base data to the saved / written data. - @return Promise + This will always prioritize our own view of the world over + incoming data. + + @param parentWriter DataStoreWriter ]=] -function DataStoreStage:LoadAll() - return self:_promiseLoadParentContent():Then(function(data) - local result = {} +function DataStoreStage:MarkDataAsSaved(parentWriter) + -- Update all children first + for key, subwriter in pairs(parentWriter:GetSubWritersMap()) do + local store = self._stores[key] + if store then + store:MarkDataAsSaved(subwriter) + else + warn("[DataStoreStage] - Store removed, but writer persists") + end + end - for key, value in pairs(data) do - if value == DataStoreDeleteToken then - result[key] = nil - elseif type(value) == "table" then - result[key] = Table.deepCopy(value) + local dataToSave = parentWriter:GetDataToSave() + if self._saveDataSnapshot == DataStoreDeleteToken or dataToSave == DataStoreDeleteToken then + if self._saveDataSnapshot == dataToSave then + self._baseDataSnapshot = nil + self._saveDataSnapshot = nil + end + elseif type(self._saveDataSnapshot) == "table" or type(dataToSave) == "table" then + if type(self._saveDataSnapshot) == "table" and type(dataToSave) == "table" then + local newSaveSnapshot = table.clone(self._saveDataSnapshot) + local newBaseDataSnapshot + if type(self._baseDataSnapshot) == "table" then + newBaseDataSnapshot = table.clone(self._baseDataSnapshot) else - result[key] = value + newBaseDataSnapshot = {} end - end - if self._dataToSave then - for key, value in pairs(self._dataToSave) do - if value == DataStoreDeleteToken then - result[key] = nil - elseif type(value) == "table" then - result[key] = Table.deepCopy(value) - else - result[key] = value + for key, value in pairs(dataToSave) do + local ourSnapshotValue = self._saveDataSnapshot[key] + if Table.deepEquivalent(ourSnapshotValue, value) or ourSnapshotValue == nil then + -- This shouldn't fire any event because our save data is matching + newBaseDataSnapshot[key] = self:_updateStoresAndComputeBaseDataSnapshotValueFromDiffSnapshot(key, value) + newSaveSnapshot[key] = nil end end - end - for key, store in pairs(self._stores) do - if store:HasWritableData() then - local writer = store:GetNewWriter() - local original = Table.deepCopy(result[key] or {}) - writer:WriteMerge(original) + self._baseDataSnapshot = table.freeze(newBaseDataSnapshot) + + if DataStoreSnapshotUtils.isEmptySnapshot(newSaveSnapshot) then + self._saveDataSnapshot = nil + else + self._saveDataSnapshot = table.freeze(newSaveSnapshot) end end + else + assert(type(self._saveDataSnapshot) ~= "table", "Case is covered above") + assert(self._saveDataSnapshot ~= DataStoreDeleteToken, "Case is covered above") + assert(dataToSave ~= DataStoreDeleteToken, "Case is covered above") + assert(type(dataToSave) ~= "table", "Case is covered above") + + -- In the none-table scenario move stuff + if self._saveDataSnapshot == dataToSave then + self._baseDataSnapshot = dataToSave + self._saveDataSnapshot = nil + end + end - return result - end) + self:_checkIntegrity() end -function DataStoreStage:_promiseLoadParentContent() +--[=[ + Helper method that when invokes ensures the data view. + + :::tip + This is a helper method. You probably want [DataStore.LoadAll] instead. + ::: + + @return Promise +]=] +function DataStoreStage:PromiseViewUpToDate() if not self._loadParent then error("[DataStoreStage.Load] - Failed to load, no loadParent!") end @@ -337,54 +467,85 @@ function DataStoreStage:_promiseLoadParentContent() error("[DataStoreStage.Load] - Failed to load, no loadName!") end - return self._loadParent:Load(self._loadName, {}) + return self._loadParent:PromiseViewUpToDate() end --[=[ - Stores the value, firing off events and queuing the item - for save. + Ovewrites the full stage with the data specified. - @param name string | number - @param value string + :::tip + Use this method carefully as it can lead to data loss in ways that a specific :Store() call + on the right stage would do better. + ::: + + @param data any ]=] -function DataStoreStage:Store(name, value) - assert(type(name) == "string", "Bad name") +function DataStoreStage:Overwrite(data) + -- Ensure that we at least start loading (and thus the autosave loop) for write + self:PromiseViewUpToDate() - if self._takenKeys[name] then - error(("[DataStoreStage] - Already have a writer for %q"):format(name)) + if data == nil then + data = DataStoreDeleteToken end - if value == nil then - value = DataStoreDeleteToken - end + if type(data) == "table" then + local newSaveSnapshot = {} - self:_doStore(name, value) -end + local remaining = Set.fromKeys(self._stores) + for key, store in pairs(self._stores) do + -- Update each store + store:Overwrite(data[key]) + end ---[=[ - Gets a sub-datastore that will write at the given name point + for key, value in pairs(data) do + remaining[key] = nil + if self._stores[key] then + self._stores[key]:Overwrite(value) + else + newSaveSnapshot[key] = value + end + end - @param name string | number - @return DataStoreStage -]=] -function DataStoreStage:GetSubStore(name) - assert(type(name) == "string" or type(name) == "number", "Bad name") + for key, _ in pairs(remaining) do + self._stores[key]:Overwrite(DataStoreDeleteToken) + end - if self._stores[name] then - return self._stores[name] - end + self._saveDataSnapshot = table.freeze(newSaveSnapshot) + else + for _, store in pairs(self._stores) do + store:Overwrite(DataStoreDeleteToken) + end - if self._takenKeys[name] then - error(("[DataStoreStage.GetSubStore] - Already have a writer for %q"):format(name)) + self._saveDataSnapshot = data end - local newStore = DataStoreStage.new(name, self) - self._takenKeys[name] = true - self._maid:GiveTask(newStore) + self:_updateViewSnapshot() +end + +--[=[ + Ovewrites the full stage with the data specified. However, it will merge the data + to help prevent data-loss. - self._stores[name] = newStore + :::tip + Use this method carefully as it can lead to data loss in ways that a specific :Store() call + on the right stage would do better. + ::: - return newStore + @param data any +]=] +function DataStoreStage:OverwriteMerge(data) + -- Ensure that we at least start loading (and thus the autosave loop) for write + self:PromiseViewUpToDate() + + if type(data) == "table" and data ~= DataStoreDeleteToken then + -- Note we explicitly don't wipe values here! Need delete token if we want to delete! + for key, value in pairs(data) do + self:_storeAtKey(key, value) + end + else + -- Non-tables + self:Overwrite(data) + end end --[=[ @@ -398,19 +559,10 @@ function DataStoreStage:StoreOnValueChange(name, valueObj) assert(type(name) == "string" or type(name) == "number", "Bad name") assert(typeof(valueObj) == "Instance" or (type(valueObj) == "table" and valueObj.Changed), "Bad valueObj") - if self._takenKeys[name] then - error(("[DataStoreStage] - Already have a writer for %q"):format(name)) - end - local maid = Maid.new() - self._takenKeys[name] = true - maid:GiveTask(function() - self._takenKeys[name] = nil - end) - maid:GiveTask(valueObj.Changed:Connect(function() - self:_doStore(name, valueObj.Value) + self:_storeAtKey(name, valueObj.Value) end)) return maid @@ -422,7 +574,7 @@ end @return boolean ]=] function DataStoreStage:HasWritableData() - if self._dataToSave then + if self._saveDataSnapshot ~= nil then return true end @@ -441,57 +593,403 @@ function DataStoreStage:HasWritableData() end --[=[ - Constructs a writer which provides a snapshot of the current data state to write + Constructs a writer which provides a snapshot of the current data state to write. + + :::tip + This is automatically invoked during saving and is public so [DataStore] can invoke it. + ::: @return DataStoreWriter ]=] function DataStoreStage:GetNewWriter() - local writer = DataStoreWriter.new() - if self._dataToSave then - writer:SetRawData(self._dataToSave) + self:_checkIntegrity() + + local writer = DataStoreWriter.new(self:GetFullPath()) + + local fullBaseDataSnapshot = self:_createFullBaseDataSnapshot() + + if self._saveDataSnapshot ~= nil then + writer:SetSaveDataSnapshot(self._saveDataSnapshot) end - for name, store in pairs(self._stores) do + for key, store in pairs(self._stores) do if not store.Destroy then - warn(("[DataStoreStage] - Substore %q destroyed"):format(name)) + warn(("[DataStoreStage] - Substore %q destroyed"):format(key)) continue end if store:HasWritableData() then - writer:AddWriter(name, store:GetNewWriter()) + writer:AddSubWriter(key, store:GetNewWriter()) end end + writer:SetFullBaseDataSnapshot(fullBaseDataSnapshot) + return writer end +--[=[ + Invokes all saving callbacks + + :::tip + This is automatically invoked before saving and is public so [DataStore] can invoke it. + ::: + + @return Promise +]=] +function DataStoreStage:PromiseInvokeSavingCallbacks() + if not next(self._savingCallbacks) then + return Promise.resolved() + end + + local removingPromises = {} + for _, func in pairs(self._savingCallbacks) do + local result = func() + if Promise.isPromise(result) then + table.insert(removingPromises, result) + end + end + + for _, substore in pairs(self._stores) do + local promise = substore:PromiseInvokeSavingCallbacks() + if promise then + table.insert(removingPromises, promise) + end + end + + return PromiseUtils.all(removingPromises) +end + +function DataStoreStage:_createFullBaseDataSnapshot() + if self._baseDataSnapshot == DataStoreDeleteToken then + error("BadDataSnapshot cannot be a delete token") + elseif type(self._baseDataSnapshot) == "table" or self._baseDataSnapshot == nil then + local newSnapshot + if type(self._baseDataSnapshot) == "table" then + newSnapshot = table.clone(self._baseDataSnapshot) + else + newSnapshot = {} + end + + for key, store in pairs(self._stores) do + if not store.Destroy then + warn(("[DataStoreStage] - Substore %q destroyed"):format(key)) + continue + end + + if not store:HasWritableData() then + newSnapshot[key] = store:_createFullBaseDataSnapshot() + end + end + + if DataStoreSnapshotUtils.isEmptySnapshot(newSnapshot) then + return nil + else + return table.freeze(newSnapshot) + end + else + return self._baseDataSnapshot + end +end + +function DataStoreStage:_updateStoresAndComputeBaseDataSnapshotFromDiffSnapshot(diffSnapshot) + if diffSnapshot == DataStoreDeleteToken then + return nil + elseif type(diffSnapshot) == "table" then + local newBaseDataSnapshot + if type(self._baseDataSnapshot) == "table" then + newBaseDataSnapshot = table.clone(self._baseDataSnapshot) + else + newBaseDataSnapshot = {} + end + + -- Merge all of our newly downloaded data here into our base layer. + for key, value in pairs(diffSnapshot) do + newBaseDataSnapshot[key] = self:_updateStoresAndComputeBaseDataSnapshotValueFromDiffSnapshot(key, value) + end + + return table.freeze(newBaseDataSnapshot) + elseif diffSnapshot == nil then + return self._baseDataSnapshot + else + return diffSnapshot + end +end + +function DataStoreStage:_updateStoresAndComputeBaseDataSnapshotValueFromDiffSnapshot(key, value) + assert(type(key) == "string" or type(key) == "number", "Bad key") + + if self._stores[key] then + self._stores[key]:MergeDiffSnapshot(value) + return nil + elseif value == DataStoreDeleteToken then + return nil + elseif type(value) == "table" and type(self._baseDataSnapshot) == "table" and type(self._baseDataSnapshot[key]) == "table" then + return self:_recurseMergeTable(self._baseDataSnapshot[key], value) + else + return value + end +end + +function DataStoreStage:_recurseMergeTable(original, incoming) + if incoming == DataStoreDeleteToken then + return nil + elseif type(incoming) == "table" and type(original) == "table" then + -- Merge + local newSnapshot = table.clone(original) + + -- Overwerite with merged values... + for key, value in pairs(incoming) do + newSnapshot[key] = self:_recurseMergeTable(original[key], value) + end + + return table.freeze(newSnapshot) + else + return incoming + end +end + +function DataStoreStage:_updateViewSnapshot() + self:_checkIntegrity() + + local newViewSnapshot = self:_computeNewViewSnapshot() + + if not Table.deepEquivalent(self._viewSnapshot, newViewSnapshot) then + local previousViewSnapshot = self._viewSnapshot + self._viewSnapshot = newViewSnapshot + + -- Fire off changed keys + local changedKeys = self:_computeChangedKeys(previousViewSnapshot, newViewSnapshot) + if next(changedKeys) ~= nil then + if type(newViewSnapshot) == "table" then + for key, _ in pairs(changedKeys) do + self._keySubscriptions:Fire(key, newViewSnapshot[key]) + end + else + for key, _ in pairs(changedKeys) do + self._keySubscriptions:Fire(key, nil) + end + end + end + + self.Changed:Fire(self._viewSnapshot) + end + + self:_checkIntegrity() +end + +function DataStoreStage:_computeChangedKeys(previousViewSnapshot, newViewSnapshot) + -- Detect keys that changed + if type(previousViewSnapshot) == "table" and type(newViewSnapshot) == "table" then + local changedKeys = {} + + local keys = Set.union(Set.fromKeys(previousViewSnapshot), Set.fromKeys(newViewSnapshot)) + for key, _ in pairs(keys) do + if not Table.deepEquivalent(previousViewSnapshot[key], newViewSnapshot[key]) then + changedKeys[key] = true + end + end + + return changedKeys + elseif type(newViewSnapshot) == "table" then + -- Swap to table, all keys change + return Set.fromKeys(newViewSnapshot) + elseif type(previousViewSnapshot) == "table" then + -- Swap from table, all keys change + return Set.fromKeys(previousViewSnapshot) + else + return {} + end +end + +function DataStoreStage:_updateViewSnapshotAtKey(key) + assert(type(key) == "string" or type(key) == "number", "Bad key") + + if type(self._viewSnapshot) ~= "table" then + self:_updateViewSnapshot() + return + end + + local newValue = self:_computeViewValueForKey(key) + if self._viewSnapshot[key] == newValue then + return + end + + local newSnapshot = table.clone(self._viewSnapshot) + newSnapshot[key] = newValue + + + self._viewSnapshot = table.freeze(newSnapshot) + self._keySubscriptions:Fire(key, newValue) + self.Changed:Fire(self._viewSnapshot) + + self:_checkIntegrity() +end + +function DataStoreStage:_computeNewViewSnapshot() + -- This prioritizes save data first, then stores, then base data + + if self._saveDataSnapshot == DataStoreDeleteToken then + return nil + elseif self._saveDataSnapshot == nil or type(self._saveDataSnapshot) == "table" then + -- Compute a new view + + -- Start with base data + local newView + if type(self._baseDataSnapshot) == "table" then + newView = table.clone(self._baseDataSnapshot) + else + newView = {} + end + + -- Add in stores + for key, store in pairs(self._stores) do + newView[key] = store._viewSnapshot + end + + -- Then finally save data + if type(self._saveDataSnapshot) == "table" then + for key, value in pairs(self._saveDataSnapshot) do + if value == DataStoreDeleteToken then + newView[key] = nil + else + newView[key] = value + end + end + end + + if next(newView) == nil and not (type(self._baseDataSnapshot) == "table" or type(self._saveDataSnapshot) == "table") then + -- We haev no reason to be a table, make sure we return nil + return nil + end + + return table.freeze(newView) + else + assert(self._saveDataSnapshot ~= nil, "Bad _saveDataSnapshot") + assert(type(self._saveDataSnapshot) ~= "table", "Bad self._saveDataSnapshot") + + -- If save data isn't nil or a table then we are to return the save table + return self._saveDataSnapshot + end +end + +function DataStoreStage:_computeViewValueForKey(key) + -- This prioritizes save data first, then stores, then base data + + if self._saveDataSnapshot == DataStoreDeleteToken then + return nil + elseif self._saveDataSnapshot == nil or type(self._saveDataSnapshot) == "table" then + if type(self._saveDataSnapshot) == "table" then + if self._saveDataSnapshot[key] ~= nil then + local value = self._saveDataSnapshot[key] + if value == DataStoreDeleteToken then + return nil + else + return value + end + end + end + + if self._stores[key] then + local value = self._stores[key]._viewSnapshot + if value == DataStoreDeleteToken then + return nil + else + return value + end + end + + if type(self._baseDataSnapshot) == "table" then + if self._baseDataSnapshot[key] ~= nil then + return self._baseDataSnapshot[key] + end + end + + return nil + else + -- If save data isn't nil or a table then we are to return nil. + return nil + end +end + -- Stores the data for overwrite. -function DataStoreStage:_doStore(name, value) - assert(type(name) == "string" or type(name) == "number", "Bad name") +function DataStoreStage:_storeAtKey(key, value) + assert(type(key) == "string" or type(key) == "number", "Bad key") assert(value ~= nil, "Bad value") - local newValue - if value == DataStoreDeleteToken then - newValue = DataStoreDeleteToken - elseif type(value) == "table" then - newValue = Table.deepCopy(value) + local deepClonedSaveValue + if type(value) == "table" then + deepClonedSaveValue = table.freeze(Table.deepCopy(value)) else - newValue = value + deepClonedSaveValue = value end - if not self._dataToSave then - self._dataToSave = {} + if self._stores[key] then + self._stores[key]:Overwrite(value) + return end - self._dataToSave[name] = newValue - if self._topLevelStoreSignal then - self._topLevelStoreSignal:Fire() + local swappedSaveSnapshotType = false + local newSnapshot + + if type(self._saveDataSnapshot) == "table" then + newSnapshot = table.clone(self._saveDataSnapshot) + else + swappedSaveSnapshotType = true + newSnapshot = {} end - if newValue == DataStoreDeleteToken then - self._subsTable:Fire(name, nil) + newSnapshot[key] = deepClonedSaveValue + + self._saveDataSnapshot = table.freeze(newSnapshot) + + self.DataStored:Fire() + + if swappedSaveSnapshotType then + self:_updateViewSnapshot() else - self._subsTable:Fire(name, newValue) + self:_updateViewSnapshotAtKey(key) + end + self:_checkIntegrity() +end + +function DataStoreStage:_checkIntegrity() + if not SLOW_INTEGRITY_CHECK_ENABLED then + return + end + + assert(self._baseDataSnapshot ~= DataStoreDeleteToken, "BaseDataSnapshot should not be DataStoreDeleteToken") + assert(self._viewSnapshot ~= DataStoreDeleteToken, "ViewSnapshot should not be DataStoreDeleteToken") + + if type(self._baseDataSnapshot) == "table" then + assert(table.isfrozen(self._baseDataSnapshot), "Base snapshot should be frozen") + end + + if type(self._saveDataSnapshot) == "table" then + assert(table.isfrozen(self._saveDataSnapshot), "Save snapshot should be frozen") + end + + if type(self._viewSnapshot) == "table" then + assert(table.isfrozen(self._viewSnapshot), "View snapshot should be frozen") + end + + for key, _ in pairs(self._stores) do + if type(self._baseDataSnapshot) == "table" and self._baseDataSnapshot[key] ~= nil then + error(string.format("[DataStoreStage] - Duplicate baseData at key %q", key)) + end + + if type(self._saveDataSnapshot) == "table" and self._saveDataSnapshot[key] ~= nil then + error(string.format("[DataStoreStage] - Duplicate saveData at key %q", key)) + end + end + + if type(self._viewSnapshot) == "table" then + for key, value in pairs(self._viewSnapshot) do + assert(type(key) == "string" or type(key) == "number", "Bad key") + if value == DataStoreDeleteToken then + error(string.format("[DataStoreStage] - View at key %q is delete token", key)) + end + end end end diff --git a/src/datastore/src/Server/Modules/DataStoreWriter.lua b/src/datastore/src/Server/Modules/DataStoreWriter.lua index c08aee99db..be189c8bbc 100644 --- a/src/datastore/src/Server/Modules/DataStoreWriter.lua +++ b/src/datastore/src/Server/Modules/DataStoreWriter.lua @@ -8,6 +8,11 @@ local require = require(script.Parent.loader).load(script) local Table = require("Table") local DataStoreDeleteToken = require("DataStoreDeleteToken") +local Symbol = require("Symbol") +local Set = require("Set") +local DataStoreSnapshotUtils = require("DataStoreSnapshotUtils") + +local UNSET_TOKEN = Symbol.named("unsetValue") local DataStoreWriter = {} DataStoreWriter.ClassName = "DataStoreWriter" @@ -16,12 +21,17 @@ DataStoreWriter.__index = DataStoreWriter --[=[ Constructs a new DataStoreWriter. In general, you will not use this API directly. + @param debugName string @return DataStoreWriter ]=] -function DataStoreWriter.new() +function DataStoreWriter.new(debugName) local self = setmetatable({}, DataStoreWriter) - self._rawSetData = {} + self._debugName = assert(debugName, "No debugName") + self._saveDataSnapshot = UNSET_TOKEN + self._fullBaseDataSnapshot = UNSET_TOKEN + self._userIdList = UNSET_TOKEN + self._writers = {} return self @@ -29,10 +39,40 @@ end --[=[ Sets the ray data to write - @param data table + @param saveDataSnapshot table | any ]=] -function DataStoreWriter:SetRawData(data) - self._rawSetData = Table.deepCopy(data) +function DataStoreWriter:SetSaveDataSnapshot(saveDataSnapshot) + assert(type(saveDataSnapshot) ~= "table" or table.isfrozen(saveDataSnapshot), "saveDataSnapshot should be frozen") + + if saveDataSnapshot == DataStoreDeleteToken then + self._saveDataSnapshot = DataStoreDeleteToken + elseif type(saveDataSnapshot) == "table" then + self._saveDataSnapshot = Table.deepCopy(saveDataSnapshot) + else + self._saveDataSnapshot = saveDataSnapshot + end +end + +function DataStoreWriter:GetDataToSave() + if self._saveDataSnapshot == UNSET_TOKEN then + return nil + end + + return self._saveDataSnapshot +end + +function DataStoreWriter:GetSubWritersMap() + return self._writers +end + +function DataStoreWriter:SetFullBaseDataSnapshot(fullBaseDataSnapshot) + assert(type(fullBaseDataSnapshot) ~= "table" or table.isfrozen(fullBaseDataSnapshot), "fullBaseDataSnapshot should be frozen") + + if fullBaseDataSnapshot == DataStoreDeleteToken then + error("[DataStoreWriter] - fullBaseDataSnapshot should not be a delete token") + end + + self._fullBaseDataSnapshot = fullBaseDataSnapshot end --[=[ @@ -40,7 +80,7 @@ end @param name string @param writer DataStoreWriter ]=] -function DataStoreWriter:AddWriter(name, writer) +function DataStoreWriter:AddSubWriter(name, writer) assert(type(name) == "string", "Bad name") assert(not self._writers[name], "Writer already exists for name") assert(writer, "Bad writer") @@ -49,37 +89,207 @@ function DataStoreWriter:AddWriter(name, writer) end --[=[ - Merges the new data into the original value + Gets a sub writer - @param original table? - @return table -- The original table + @param name string + @return DataStoreWriter ]=] -function DataStoreWriter:WriteMerge(original) - original = original or {} +function DataStoreWriter:GetWriter(name) + assert(type(name) == "string", "Bad name") + + return self._writers[name] +end - for key, value in pairs(self._rawSetData) do - if value == DataStoreDeleteToken then - original[key] = nil +--[=[ + Merges the incoming data. + + Won't really perform a delete operation because we can't be sure if we were suppose to have reified this stuff or not. + + @param incoming any +]=] +function DataStoreWriter:ComputeDiffSnapshot(incoming) + assert(incoming ~= DataStoreDeleteToken, "Incoming value should not be DataStoreDeleteToken") + + if type(incoming) == "table" then + local keys = Set.union(Set.fromKeys(self._writers), Set.fromKeys(incoming)) + + local baseSnapshot + if type(self._fullBaseDataSnapshot) == "table" then + baseSnapshot = self._fullBaseDataSnapshot + Set.unionUpdate(keys, Set.fromKeys(self._fullBaseDataSnapshot)) else - original[key] = value + baseSnapshot = {} end - end - for key, writer in pairs(self._writers) do - if self._rawSetData[key] ~= nil then - warn(("[DataStoreWriter.WriteMerge] - Overwritting key %q already saved as rawData with a writer") - :format(tostring(key))) + local diffSnapshot = {} + for key, _ in pairs(keys) do + if self._writers[key] then + diffSnapshot[key] = self._writers[key]:ComputeDiffSnapshot(incoming[key]) + else + diffSnapshot[key] = self:_computeValueDiff(baseSnapshot[key], incoming[key]) + end end - local result = writer:WriteMerge(original[key]) - if result == DataStoreDeleteToken then - original[key] = nil + if not DataStoreSnapshotUtils.isEmptySnapshot(diffSnapshot) then + return table.freeze(diffSnapshot) + else + if next(keys) then + return nil -- No delta + else + return DataStoreDeleteToken + end + end + else + return self:_computeValueDiff(self._fullBaseDataSnapshot, incoming) + end +end + +function DataStoreWriter:_computeValueDiff(original, incoming) + assert(original ~= DataStoreDeleteToken, "original cannot be DataStoreDeleteToken") + assert(incoming ~= DataStoreDeleteToken, "incoming cannot be DataStoreDeleteToken") + + if original == incoming then + return nil + elseif original ~= nil and incoming == nil then + return DataStoreDeleteToken + elseif type(original) == "table" and type(incoming) == "table" then + return self:_computeTableDiff(original, incoming) + else + return incoming + end +end + +function DataStoreWriter:_computeTableDiff(original, incoming) + assert(type(original) == "table", "Bad original") + assert(type(incoming) == "table", "Bad incoming") + + local keys = Set.union(Set.fromKeys(original), Set.fromKeys(incoming)) + + local diffSnapshot = {} + for key, _ in pairs(keys) do + diffSnapshot[key] = self:_computeValueDiff(original[key], incoming[key]) + end + + if not DataStoreSnapshotUtils.isEmptySnapshot(diffSnapshot) then + return table.freeze(diffSnapshot) + else + if next(keys) then + return nil -- No delta else - original[key] = result + return DataStoreDeleteToken end end +end + +--[=[ + Set of user ids to write with the data (only applies to top-level writer) + + @param userIdList { number } +]=] +function DataStoreWriter:SetUserIdList(userIdList) + assert(type(userIdList) == "table" or userIdList == nil, "Bad userIdList") + + self._userIdList = userIdList +end + +--[=[ + User ids to associate with data + + @return userIdList { number } +]=] +function DataStoreWriter:GetUserIdList() + if self._userIdList == UNSET_TOKEN then + return nil + end + + return self._userIdList +end + +function DataStoreWriter:_writeMergeWriters(original) + local copy + if type(original) == "table" then + copy = table.clone(original) + else + copy = original + end + + if next(self._writers) ~= nil then + -- Original was not a table. We need to swap to one. + if type(copy) ~= "table" then + copy = {} + end + + -- Write our writers first... + for key, writer in pairs(self._writers) do + local result = writer:WriteMerge(copy[key]) + if result == DataStoreDeleteToken then + copy[key] = nil + else + copy[key] = result + end + end + end + + -- Write our save data next + if type(self._saveDataSnapshot) == "table" and next(self._saveDataSnapshot) ~= nil then + -- Original was not a table. We need to swap to one. + if type(copy) ~= "table" then + copy = {} + end + + for key, value in pairs(self._saveDataSnapshot) do + if self._writers[key] then + warn(string.format("[DataStoreWriter._writeMergeWriters] - Overwriting key %q already saved as rawData with a writer with %q (was %q)", key, tostring(value), tostring(copy[key]))) + end + + if value == DataStoreDeleteToken then + copy[key] = nil + else + copy[key] = value + end + end + end + + -- Handle empty table scenario.. + -- This would also imply our original is nil somehow... + if next(copy) == nil then + if type(self._saveDataSnapshot) ~= "table" then + return nil + end + end + + return copy +end + +--[=[ + Merges the new data into the original value + + @param original any + @return any -- The original value +]=] +function DataStoreWriter:WriteMerge(original) + -- Prioritize save value first, followed by writers, followed by original value + + if self._saveDataSnapshot == DataStoreDeleteToken then + return DataStoreDeleteToken + elseif self._saveDataSnapshot == UNSET_TOKEN or self._saveDataSnapshot == nil or type(self._saveDataSnapshot) == "table" then + return self:_writeMergeWriters(original) + else + -- Save data must be a boolean or something + return self._saveDataSnapshot + end +end + +function DataStoreWriter:IsCompleteWipe() + if self._saveDataSnapshot == UNSET_TOKEN then + return false + end + + if self._saveDataSnapshot == DataStoreDeleteToken then + return true + end - return original + return false end return DataStoreWriter \ No newline at end of file diff --git a/src/datastore/src/Server/PlayerDataStoreManager.lua b/src/datastore/src/Server/PlayerDataStoreManager.lua index e40fd5b393..4761782f98 100644 --- a/src/datastore/src/Server/PlayerDataStoreManager.lua +++ b/src/datastore/src/Server/PlayerDataStoreManager.lua @@ -171,6 +171,7 @@ function PlayerDataStoreManager:_createDataStore(player) assert(not self._datastores[player], "Bad player") local datastore = DataStore.new(self._robloxDataStore, self:_getKey(player)) + datastore:SetUserIdList({ player.UserId }) self._maid._savingConns[player] = datastore.Saving:Connect(function(promise) self._pendingSaves:Add(promise) diff --git a/src/datastore/src/Server/Utility/DataStorePromises.lua b/src/datastore/src/Server/Utility/DataStorePromises.lua index 9f76c6adad..dfe016c8cd 100644 --- a/src/datastore/src/Server/Utility/DataStorePromises.lua +++ b/src/datastore/src/Server/Utility/DataStorePromises.lua @@ -69,13 +69,14 @@ function DataStorePromises.getAsync(robloxDataStore, key) return Promise.spawn(function(resolve, reject) local result = nil + local dataStoreKeyInfo = nil local ok, err = pcall(function() - result = robloxDataStore:GetAsync(key) + result, dataStoreKeyInfo = robloxDataStore:GetAsync(key) end) if not ok then return reject(err) end - return resolve(result) + return resolve(result, dataStoreKeyInfo) end) end diff --git a/src/datastore/test/default.project.json b/src/datastore/test/default.project.json new file mode 100644 index 0000000000..6b4ab69c83 --- /dev/null +++ b/src/datastore/test/default.project.json @@ -0,0 +1,21 @@ +{ + "name": "DataStoreTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "datastore": { + "$path": ".." + }, + "Script": { + "$path": "scripts/Server" + } + }, + "StarterPlayer": { + "StarterPlayerScripts": { + "Main": { + "$path": "scripts/Client" + } + } + } + } +} \ No newline at end of file diff --git a/src/datastore/test/scripts/Client/ClientMain.client.lua b/src/datastore/test/scripts/Client/ClientMain.client.lua new file mode 100644 index 0000000000..485bca1eac --- /dev/null +++ b/src/datastore/test/scripts/Client/ClientMain.client.lua @@ -0,0 +1,10 @@ +--[[ + @class ClientMain +]] + +local packages = game:GetService("ReplicatedStorage"):WaitForChild("Packages") + +local serviceBag = require(packages.ServiceBag).new() + +serviceBag:Init() +serviceBag:Start() \ No newline at end of file diff --git a/src/datastore/test/scripts/Server/ServerMain.server.lua b/src/datastore/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 0000000000..a62453d08e --- /dev/null +++ b/src/datastore/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,120 @@ +--[[ + @class ServerMain +]] + +local ServerScriptService = game:GetService("ServerScriptService") +local HttpService = game:GetService("HttpService") + +local loader = ServerScriptService:FindFirstChild("LoaderUtils", true).Parent +local packages = require(loader).bootstrapGame(ServerScriptService.datastore) + +local Maid = require(packages.Maid) +local Promise = require(packages.Promise) + +local TURN_TIME = 8 + +local function spinUpGameCopy(prefix) + assert(type(prefix) == "string", "Bad prefix") + + local serviceBag = require(packages.ServiceBag).new() + serviceBag:GetService(require(packages.GameDataStoreService)) + serviceBag:GetService(require(packages.PlayerDataStoreService)) + + serviceBag:Init() + serviceBag:Start() + + local guid = prefix .. " " .. HttpService:GenerateGUID(false) + local maid = Maid.new() + + local gameDataStore = serviceBag:GetService(require(packages.GameDataStoreService)) + local bindToCloseService = serviceBag:GetService(require(packages.BindToCloseService)) + + -- This would be an aggressive usage of this area, it probably won't scale well enough. + -- But writing some shared code or something like API keys should scale fine. + maid:GivePromise(gameDataStore:PromiseDataStore()):Then(function(dataStore) + local substore = dataStore:GetSubStore("AliveServers") + substore:Store(guid, true) + + -- maid:GiveTask(dataStore:Observe():Subscribe(function(viewSnapshot) + -- print(string.format("(%s) dataStore:Observe()", prefix), viewSnapshot) + -- end)) + + if prefix == "blue" then + dataStore:SetDoDebugWriting(true) + dataStore:SetSyncOnSave(true) + dataStore:SetAutoSaveTimeSeconds(4) + + -- maid:GiveTask(dataStore:Observe():Subscribe(function(viewSnapshot) + -- print(string.format("(%s) dataStore:Observe()", prefix), viewSnapshot) + -- end)) + + task.delay(4*TURN_TIME, function() + warn("Blue server is restoring data") + + substore:Store(guid, true) + end) + elseif prefix == "red" then + warn(string.format("%s server is storing data", prefix)) + + -- dataStore:SetDoDebugWriting(true) + dataStore:SetSyncOnSave(true) + dataStore:SetAutoSaveTimeSeconds(4) + -- dataStore:Save() + + task.delay(TURN_TIME, function() + warn(string.format("%s server is wiping data", prefix)) + + substore:Wipe() + -- dataStore:Save() + + task.delay(TURN_TIME, function() + warn(string.format("%s server is adding substore data", prefix)) + + substore:Store(guid, { + playerCount = 5; + startTime = DateTime.now().UnixTimestamp + }) + -- dataStore:Save() + + task.delay(TURN_TIME, function() + warn(string.format("%s server is changing player count", prefix)) + local guidStore = substore:GetSubStore(guid) + guidStore:Store("playerCount", 25) + -- dataStore:Save() + end) + end) + end) + end + + -- TODO: Update some random numbers every second for a while.... + + -- TODO: Force saving twice + + maid:GiveTask(dataStore:Observe():Subscribe(function(viewSnapshot) + print(string.format("(%s) dataStore:Observe()", prefix), viewSnapshot) + end)) + + -- dataStore:LoadAll():Then(function(data) + -- -- print(string.format("[%s][LoadAll] - Load all", prefix), data) + -- end) + + -- local entrySubstore = substore:GetSubStore(guid) + -- entrySubstore:LoadAll():Then(function(data) + -- -- print(string.format("[%s][SUBSTORE][LoadAll] Loaded substore", prefix), data) + -- end) + + -- entrySubstore:Overwrite(os.clock()) + + maid:GiveTask(bindToCloseService:RegisterPromiseOnCloseCallback(function() + substore:Delete(guid) + return Promise.resolved() + end)) + + end) + + return maid +end + +spinUpGameCopy("red") +spinUpGameCopy("blue") +spinUpGameCopy("green") \ No newline at end of file diff --git a/src/defaultvalueutils/README.md b/src/defaultvalueutils/README.md new file mode 100644 index 0000000000..646728418a --- /dev/null +++ b/src/defaultvalueutils/README.md @@ -0,0 +1,23 @@ +## DefaultValueUtils + + + +Helps get the default or zero value for value types in Roblox + + + +## Installation + +``` +npm install @quenty/defaultvalueutils --save +``` diff --git a/src/defaultvalueutils/default.project.json b/src/defaultvalueutils/default.project.json new file mode 100644 index 0000000000..765e2848f5 --- /dev/null +++ b/src/defaultvalueutils/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "defaultvalueutils", + "tree": { + "$path": "src" + } +} diff --git a/src/defaultvalueutils/package.json b/src/defaultvalueutils/package.json new file mode 100644 index 0000000000..fc45b4e16a --- /dev/null +++ b/src/defaultvalueutils/package.json @@ -0,0 +1,30 @@ +{ + "name": "@quenty/defaultvalueutils", + "version": "1.0.0", + "description": "Helps get the default or zero value for value types in Roblox", + "keywords": [ + "Roblox", + "Nevermore", + "Lua", + "defaultvalueutils" + ], + "bugs": { + "url": "https://github.com/Quenty/NevermoreEngine/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Quenty/NevermoreEngine.git", + "directory": "src/defaultvalueutils/" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/quenty" + }, + "license": "MIT", + "contributors": [ + "Quenty" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/src/defaultvalueutils/src/Shared/DefaultValueUtils.lua b/src/defaultvalueutils/src/Shared/DefaultValueUtils.lua new file mode 100644 index 0000000000..f2e7b0e86d --- /dev/null +++ b/src/defaultvalueutils/src/Shared/DefaultValueUtils.lua @@ -0,0 +1,80 @@ +--[=[ + Helps get the default or zero value for value types in Roblox + @class DefaultValueUtils +]=] + +local DefaultValueUtils = {} + +-- selene: allow(incorrect_standard_library_use) +local DEFAULT_VALUES = { + ["boolean"] = false; + ["BrickColor"] = BrickColor.new(); + ["CFrame"] = CFrame.new(); + ["Color3"] = Color3.new(); + ["ColorSequence"] = ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(0, 0, 0)); ColorSequenceKeypoint.new(1, Color3.new(0, 0, 0)); }); + ["ColorSequenceKeypoint"] = ColorSequenceKeypoint.new(0, Color3.new(0, 0, 0)); + ["number"] = 0; + ["PhysicalProperties"] = PhysicalProperties.new(Enum.Material.Plastic); -- Eww + ["NumberRange"] = NumberRange.new(0); + ["NumberSequence"] = NumberSequence.new({ NumberSequenceKeypoint.new(0, 0); NumberSequenceKeypoint.new(1, 0); }); + ["NumberSequenceKeypoint"] = NumberSequenceKeypoint.new(0, 0); + ["Ray"] = Ray.new(); + ["Rect"] = Rect.new(); + ["Region3"] = Region3.new(); + ["Region3int16"] = Region3int16.new(); + ["string"] = ""; + ["UDim"] = UDim.new(); + ["UDim2"] = UDim2.new(); + ["userdata"] = newproxy(); + ["Vector2"] = Vector2.zero; + ["Vector2int16"] = Vector2int16.new(); + ["Vector3"] = Vector3.zero; + ["Vector3int16"] = Vector3int16.new(); +} + +--[=[ + Returns the default value for a given value type. If the type is mutable than + a new value will ge cosntructed. + + @param typeOfName string + @return any +]=] +function DefaultValueUtils.getDefaultValueForType(typeOfName) + if DEFAULT_VALUES[typeOfName] ~= nil then + return DEFAULT_VALUES[typeOfName] + elseif typeOfName == "table" then + return {} + elseif typeOfName == "nil" then + return nil + elseif typeOfName == "Random" then + return Random.new() + elseif typeOfName == "RaycastParams" then + return RaycastParams.new() + elseif typeOfName == "OverlapParams" then + return OverlapParams.new() + elseif typeOfName == "Instance" then + error("Cannot get a defaultValue for an instance") + else + error(string.format("Unknown type %q", typeOfName)) + end +end + +--[=[ + Converts this value to its default value. If it's a table, it applies it recursively. + + @param value T + @return T +]=] +function DefaultValueUtils.toDefaultValue(value) + if type(value) == "table" then + local result = {} + for key, item in pairs(value) do + result[key] = DefaultValueUtils.toDefaultValue(item) + end + return result + else + return DefaultValueUtils.getDefaultValueForType(typeof(value)) + end +end + +return DefaultValueUtils \ No newline at end of file diff --git a/src/defaultvalueutils/src/node_modules.project.json b/src/defaultvalueutils/src/node_modules.project.json new file mode 100644 index 0000000000..46233dac4f --- /dev/null +++ b/src/defaultvalueutils/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/src/defaultvalueutils/test/default.project.json b/src/defaultvalueutils/test/default.project.json new file mode 100644 index 0000000000..681465bfa3 --- /dev/null +++ b/src/defaultvalueutils/test/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "DefaultValueUtilsTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "defaultvalueutils": { + "$path": ".." + } + } + } +} \ No newline at end of file diff --git a/src/draw/src/Shared/Draw.lua b/src/draw/src/Shared/Draw.lua index c69a5ceeb5..e975ce3fe6 100644 --- a/src/draw/src/Shared/Draw.lua +++ b/src/draw/src/Shared/Draw.lua @@ -77,7 +77,7 @@ end --[=[ Draws a line between directions - @param start Vector3 + @param origin Vector3 @param direction Vector3 @param color Color3 -- Optional @param parent Instance? -- Optional @@ -85,12 +85,12 @@ end @param diameter number -- Optional @return Instance ]=] -function Draw.direction(start, direction, color, parent, meshDiameter, diameter) - start = assert(Draw._toVector3(start), "Bad start") +function Draw.direction(origin, direction, color, parent, meshDiameter, diameter) + origin = assert(Draw._toVector3(origin), "Bad origin") direction = assert(Draw._toVector3(direction), "Bad direction") color = Draw._toColor3(color) - return Draw.ray(Ray.new(start, direction), color, parent, meshDiameter, diameter) + return Draw.ray(Ray.new(origin, direction), color, parent, meshDiameter, diameter) end --[=[ @@ -135,6 +135,25 @@ function Draw.blockcast(cframe, size, direction, color, parent) return folder end +--[=[ + Draws a raycast for debugging + + ```lua + Draw.raycast(origin, direction) + ``` + + @param origin Vector3 + @param direction Vector3 + @param color Color3 -- Optional + @param parent Instance? -- Optional + @param meshDiameter number -- Optional + @param diameter number -- Optional + @return Instance +]=] +function Draw.raycast(origin, direction, color, parent, meshDiameter, diameter) + return Draw.direction(origin, direction, color, parent, meshDiameter, diameter) +end + --[=[ Draws a ray for debugging. diff --git a/src/gameconfig/src/Shared/Config/Picker/GameConfigPicker.lua b/src/gameconfig/src/Shared/Config/Picker/GameConfigPicker.lua index 7483a3c3d4..440563c108 100644 --- a/src/gameconfig/src/Shared/Config/Picker/GameConfigPicker.lua +++ b/src/gameconfig/src/Shared/Config/Picker/GameConfigPicker.lua @@ -17,6 +17,12 @@ local GameConfigPicker = setmetatable({}, BaseObject) GameConfigPicker.ClassName = "GameConfigPicker" GameConfigPicker.__index = GameConfigPicker +--[=[ + Constructs a new game config picker. Should be gotten by [GameConfigService]. + + @param gameConfigBinder Binder + @param gameConfigAssetBinder Binder +]=] function GameConfigPicker.new(gameConfigBinder, gameConfigAssetBinder) local self = setmetatable(BaseObject.new(), GameConfigPicker) @@ -283,4 +289,29 @@ function GameConfigPicker:ToAssetId(assetType, assetIdOrKey) return assetIdOrKey end +--[=[ + Observes a converted asset type and key to an id + + @param assetType GameConfigAssetType + @param assetIdOrKey number | string + @return Observable> +]=] +function GameConfigPicker:ObserveToAssetIdBrio(assetType, assetIdOrKey) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + assert(type(assetIdOrKey) == "number" or type(assetIdOrKey) == "string", "Bad assetIdOrKey") + + if type(assetIdOrKey) == "string" then + return self:ObserveActiveAssetOfAssetTypeAndKeyBrio(assetType, assetIdOrKey):Pipe({ + RxBrioUtils.switchMapBrio(function(asset) + return asset:ObserveAssetId() + end); + }) + elseif type(assetIdOrKey) == "number" then + return RxBrioUtils.of(assetIdOrKey) + else + error("Bad idOrKey") + end +end + + return GameConfigPicker \ No newline at end of file diff --git a/src/gameconfig/src/Shared/GameConfigTags.rbxmx b/src/gameconfig/src/Shared/GameConfigTags.rbxmx new file mode 100644 index 0000000000..9887306ef8 --- /dev/null +++ b/src/gameconfig/src/Shared/GameConfigTags.rbxmx @@ -0,0 +1,33 @@ + + true + null + nil + + + + TagList + -1 + VGFnRWRpdG9yVGFnQ29udGFpbmVy + + + + + GameConfig + -1 + + + + + + + GameConfigAsset + -1 + + + + + \ No newline at end of file diff --git a/src/gameproductservice/src/Client/GameProductServiceClient.lua b/src/gameproductservice/src/Client/GameProductServiceClient.lua index edbc8624c9..6d9bd342f4 100644 --- a/src/gameproductservice/src/Client/GameProductServiceClient.lua +++ b/src/gameproductservice/src/Client/GameProductServiceClient.lua @@ -84,6 +84,34 @@ function GameProductServiceClient:Start() end)) end +--[=[ + Fires when the specified player purchases an asset + + @param assetType GameConfigAssetType + @param idOrKey string | number + @return Observable<> +]=] +function GameProductServiceClient:ObservePlayerAssetPurchased(assetType, idOrKey) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return self._helper:ObservePlayerAssetPurchased(assetType, idOrKey) +end + +--[=[ + Fires when any player purchases an asset + + @param assetType GameConfigAssetType + @param idOrKey string | number + @return Observable +]=] +function GameProductServiceClient:ObserveAssetPurchased(assetType, idOrKey) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return self._helper:ObserveAssetPurchased(assetType, idOrKey) +end + --[=[ Returns true if item has been purchased this session diff --git a/src/gameproductservice/src/Client/Manager/PlayerProductManagerClient.lua b/src/gameproductservice/src/Client/Manager/PlayerProductManagerClient.lua index 272284c09d..e640106b58 100644 --- a/src/gameproductservice/src/Client/Manager/PlayerProductManagerClient.lua +++ b/src/gameproductservice/src/Client/Manager/PlayerProductManagerClient.lua @@ -9,17 +9,16 @@ local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local BaseObject = require("BaseObject") -local PlayerProductManagerConstants = require("PlayerProductManagerConstants") -local GameConfigServiceClient = require("GameConfigServiceClient") local GameConfigAssetTypes = require("GameConfigAssetTypes") +local GameConfigAssetTypeUtils = require("GameConfigAssetTypeUtils") +local GameConfigServiceClient = require("GameConfigServiceClient") local PlayerMarketeer = require("PlayerMarketeer") +local Remoting = require("Remoting") local PlayerProductManagerClient = setmetatable({}, BaseObject) PlayerProductManagerClient.ClassName = "PlayerProductManagerClient" PlayerProductManagerClient.__index = PlayerProductManagerClient -require("PromiseRemoteEventMixin"):Add(PlayerProductManagerClient, PlayerProductManagerConstants.REMOTE_EVENT_NAME) - function PlayerProductManagerClient.new(obj, serviceBag) local self = setmetatable(BaseObject.new(obj), PlayerProductManagerClient) @@ -27,9 +26,19 @@ function PlayerProductManagerClient.new(obj, serviceBag) self._gameConfigServiceClient = self._serviceBag:GetService(GameConfigServiceClient) if self._obj == Players.LocalPlayer then + self._remoting = Remoting.new(self._obj, "PlayerProductManager") + self._maid:GiveTask(self._remoting) + self._marketeer = PlayerMarketeer.new(self._obj, self._gameConfigServiceClient:GetConfigPicker()) self._maid:GiveTask(self._marketeer) + self._marketeer:GetAssetTrackerOrError(GameConfigAssetTypes.PRODUCT):SetReceiptProcessingExpected(true) + + self._maid:GiveTask(self._remoting.NotifyReceiptProcessed:Connect(function(receipt) + local assetTracker = self._marketeer:GetAssetTrackerOrError(GameConfigAssetTypes.PRODUCT) + assetTracker:HandleProcessReceipt(self._obj, receipt) + end)) + self:_connectMarketplace() -- Configure remote events @@ -50,12 +59,15 @@ function PlayerProductManagerClient:GetMarketeer() end function PlayerProductManagerClient:_replicateRemoteEventType(assetType) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + local tracker = self._marketeer:GetAssetTrackerOrError(assetType) self._maid:GiveTask(tracker.PromptFinished:Connect(function(assetId, isPurchased) - self:PromiseRemoteEvent():Then(function(remoteEvent) - remoteEvent:FireServer(PlayerProductManagerConstants.NOTIFY_PROMPT_FINISHED, assetType, assetId, isPurchased) - end) + assert(type(assetId) == "number", "Bad assetId") + assert(type(isPurchased) == "boolean", "Bad isPurchased") + + self._remoting.NotifyPromptFinished:FireServer(assetType, assetId, isPurchased) end)) end diff --git a/src/gameproductservice/src/Server/GameProductService.lua b/src/gameproductservice/src/Server/GameProductService.lua index 78d2087d8a..305d2f3af9 100644 --- a/src/gameproductservice/src/Server/GameProductService.lua +++ b/src/gameproductservice/src/Server/GameProductService.lua @@ -90,6 +90,33 @@ function GameProductService:Start() end)) end +--[=[ + Fires when the specified player purchases an asset + + @param assetType GameConfigAssetType + @param idOrKey string | number + @return Observable<> +]=] +function GameProductService:ObservePlayerAssetPurchased(assetType, idOrKey) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return self._helper:ObservePlayerAssetPurchased(assetType, idOrKey) +end + +--[=[ + Fires when any player purchases an asset + + @param assetType GameConfigAssetType + @param idOrKey string | number + @return Observable +]=] +function GameProductService:ObserveAssetPurchased(assetType, idOrKey) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return self._helper:ObserveAssetPurchased(assetType, idOrKey) +end --[=[ Returns true if item has been purchased this session @@ -190,6 +217,9 @@ function GameProductService:_handleProcessReceipt(receiptInfo) return Enum.ProductPurchaseDecision.PurchaseGranted end +--[=[ + Cleans up the game product service +]=] function GameProductService:Destroy() self._maid:DoCleaning() end diff --git a/src/gameproductservice/src/Server/Manager/PlayerProductManager.lua b/src/gameproductservice/src/Server/Manager/PlayerProductManager.lua index 0498a13a8f..93f0c6d100 100644 --- a/src/gameproductservice/src/Server/Manager/PlayerProductManager.lua +++ b/src/gameproductservice/src/Server/Manager/PlayerProductManager.lua @@ -7,12 +7,12 @@ local require = require(script.Parent.loader).load(script) -local GameConfigService = require("GameConfigService") local BaseObject = require("BaseObject") -local PlayerProductManagerConstants = require("PlayerProductManagerConstants") +local GameConfigAssetTypes = require("GameConfigAssetTypes") local GameConfigAssetTypeUtils = require("GameConfigAssetTypeUtils") +local GameConfigService = require("GameConfigService") local PlayerMarketeer = require("PlayerMarketeer") -local GameConfigAssetTypes = require("GameConfigAssetTypes") +local Remoting = require("Remoting") local PlayerProductManager = setmetatable({}, BaseObject) PlayerProductManager.ClassName = "PlayerProductManager" @@ -37,14 +37,12 @@ function PlayerProductManager.new(player, serviceBag) -- Expect configuration on receipt processing self._marketeer:GetAssetTrackerOrError(GameConfigAssetTypes.PRODUCT):SetReceiptProcessingExpected(true) - self._remoteEvent = Instance.new("RemoteEvent") - self._remoteEvent.Name = PlayerProductManagerConstants.REMOTE_EVENT_NAME - self._remoteEvent.Archivable = false - self._remoteEvent.Parent = self._obj - self._maid:GiveTask(self._remoteEvent) + self._remoting = Remoting.new(self._obj, "PlayerProductManager") + self._remoting:DeclareEvent("NotifyReceiptProcessed") + self._maid:GiveTask(self._remoting) - self._maid:GiveTask(self._remoteEvent.OnServerEvent:Connect(function(...) - self:_handleServerEvent(...) + self._maid:GiveTask(self._remoting.NotifyPromptFinished:Connect(function(...) + self:_handlePromptFinished(...) end)) -- Initialize attributes @@ -65,18 +63,6 @@ function PlayerProductManager:GetPlayer() return self._obj end -function PlayerProductManager:_handleServerEvent(player, request, ...) - assert(self._obj == player, "Bad player") - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - assert(type(request) == "string", "Bad request") - - if request == PlayerProductManagerConstants.NOTIFY_PROMPT_FINISHED then - self:_handlePromptFinished(player, ...) - else - error(("Bad request %q"):format(PlayerProductManager)) - end -end - function PlayerProductManager:_handlePromptFinished(player, assetType, assetId, isPurchased) assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") @@ -96,8 +82,13 @@ end ]=] function PlayerProductManager:HandleProcessReceipt(player, receiptInfo) assert(self._obj == player, "Bad player") + assert(type(receiptInfo) == "table", "Bad receiptInfo") local assetTracker = self._marketeer:GetAssetTrackerOrError(GameConfigAssetTypes.PRODUCT) + + -- Notify the player + self._remoting.NotifyReceiptProcessed:FireClient(self._obj, receiptInfo) + return assetTracker:HandleProcessReceipt(player, receiptInfo) end diff --git a/src/gameproductservice/src/Shared/Helpers/GameProductServiceHelper.lua b/src/gameproductservice/src/Shared/Helpers/GameProductServiceHelper.lua index 5bdc482724..a320fff88a 100644 --- a/src/gameproductservice/src/Shared/Helpers/GameProductServiceHelper.lua +++ b/src/gameproductservice/src/Shared/Helpers/GameProductServiceHelper.lua @@ -1,4 +1,6 @@ --[=[ + Helper that is used for each game product service. See [GameProductService]. + @class GameProductServiceHelper ]=] @@ -11,11 +13,17 @@ local RxBrioUtils = require("RxBrioUtils") local GameConfigAssetTypeUtils = require("GameConfigAssetTypeUtils") local RxStateStackUtils = require("RxStateStackUtils") local Promise = require("Promise") +local Rx = require("Rx") local GameProductServiceHelper = setmetatable({}, BaseObject) GameProductServiceHelper.ClassName = "GameProductServiceHelper" GameProductServiceHelper.__index = GameProductServiceHelper +--[=[ + Helper to observe state for the game product service + + @param playerProductManagerBinder Binder +]=] function GameProductServiceHelper.new(playerProductManagerBinder) local self = setmetatable(BaseObject.new(), GameProductServiceHelper) @@ -104,6 +112,12 @@ function GameProductServiceHelper:PromiseIsOwnable(player, assetType) end) end +--[=[ + Promises the player prompt as opened + + @param player Player + @return Promise +]=] function GameProductServiceHelper:PromisePlayerIsPromptOpen(player) assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") @@ -136,6 +150,66 @@ function GameProductServiceHelper:ObservePlayerOwnership(player, assetType, idOr }) end +--[=[ + Fires when the specified player purchases an asset + + @param player Player + @param assetType GameConfigAssetType + @param idOrKey string | number + @return Observable<> +]=] +function GameProductServiceHelper:ObservePlayerAssetPurchased(player, assetType, idOrKey) + assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return self:_observePlayerProductManagerBrio(player):Pipe({ + RxBrioUtils.switchMapBrio(function(playerProductManager) + local marketeer = playerProductManager:GetMarketeer() + local ownershipTracker = marketeer:GetOwnershipTrackerOrError(assetType) + return ownershipTracker:ObserveAssetPurchased(idOrKey) + end); + Rx.map(function(_brio) + return true + end) + }) +end + +--[=[ + Fires when any player purchases an asset + + @param assetType GameConfigAssetType + @param idOrKey string | number + @return Observable +]=] +function GameProductServiceHelper:ObserveAssetPurchased(assetType, idOrKey) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return self._playerProductManagerBinder:ObserveAllBrio():Pipe({ + RxBrioUtils.flatMapBrio(function(playerProductManager) + local marketeer = playerProductManager:GetMarketeer() + local assetTracker = marketeer:GetAssetTrackerOrError(assetType) + return assetTracker:ObserveAssetPurchased(idOrKey):Pipe({ + Rx.map(function() + return playerProductManager:GetPlayer() + end); + }) + end); + Rx.map(function(brio) + -- I THINK THIS LEAKS + if brio:IsDead() then + return nil + end + + return brio:GetValue() + end); + Rx.where(function(value) + return value ~= nil + end); + }) +end + --[=[ Checks if the asset is ownable and if it is, checks player ownership. Otherwise, it checks if the asset has been purchased this session. If the asset has not been purchased this session it prompts the user to diff --git a/src/gameproductservice/src/Shared/Manager/PlayerProductManagerConstants.lua b/src/gameproductservice/src/Shared/Manager/PlayerProductManagerConstants.lua deleted file mode 100644 index 245081120d..0000000000 --- a/src/gameproductservice/src/Shared/Manager/PlayerProductManagerConstants.lua +++ /dev/null @@ -1,14 +0,0 @@ ---[=[ - @class PlayerProductManagerConstants -]=] - -local require = require(script.Parent.loader).load(script) - -local Table = require("Table") - -return Table.readonly({ - REMOTE_EVENT_NAME = "PlayerProductManagerRemoteEvent"; - - -- Client -> Server - NOTIFY_PROMPT_FINISHED = "notifyPromptFinished"; -}) \ No newline at end of file diff --git a/src/gameproductservice/src/Shared/Ownership/PlayerAssetOwnershipTracker.lua b/src/gameproductservice/src/Shared/Ownership/PlayerAssetOwnershipTracker.lua index e84e3b0042..47ed8e9e25 100644 --- a/src/gameproductservice/src/Shared/Ownership/PlayerAssetOwnershipTracker.lua +++ b/src/gameproductservice/src/Shared/Ownership/PlayerAssetOwnershipTracker.lua @@ -9,6 +9,7 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") +local GameConfigAssetTypeUtils = require("GameConfigAssetTypeUtils") local Maid = require("Maid") local ObservableMapSet = require("ObservableMapSet") local PlayerAssetOwnershipUtils = require("PlayerAssetOwnershipUtils") @@ -25,6 +26,8 @@ PlayerAssetOwnershipTracker.ClassName = "PlayerAssetOwnershipTracker" PlayerAssetOwnershipTracker.__index = PlayerAssetOwnershipTracker function PlayerAssetOwnershipTracker.new(player, configPicker, assetType, marketTracker) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + local self = setmetatable(BaseObject.new(), PlayerAssetOwnershipTracker) self._player = assert(player, "No player") @@ -84,7 +87,7 @@ function PlayerAssetOwnershipTracker:_promiseQueryIdOrKeyOwnershipCached(idOrKey local id = self._configPicker:ToAssetId(self._assetType, idOrKey) if not id then - warn(("[PlayerAssetOwnershipTracker] - Nothing with key %q"):format(tostring(idOrKey))) + warn(("[PlayerAssetOwnershipTracker._promiseQueryIdOrKeyOwnershipCached] - Nothing with key %q"):format(tostring(idOrKey))) return Promise.resolved(false) end @@ -234,7 +237,7 @@ function PlayerAssetOwnershipTracker:_getWellKnownAssets(idOrKey) elseif type(idOrKey) == "string" then return self._assetKeyToWellKnownOwnershipTracker:GetListForKey(idOrKey) else - error("Bad idOrKey") + error("[PlayerAssetOwnershipTracker._getWellKnownAssets] - Bad idOrKey") end end @@ -244,7 +247,7 @@ function PlayerAssetOwnershipTracker:_observeWellKnownAsset(idOrKey) elseif type(idOrKey) == "string" then return self._assetKeyToWellKnownOwnershipTracker:ObserveItemsForKeyBrio(idOrKey) else - error("Bad idOrKey") + error("[PlayerAssetOwnershipTracker._observeWellKnownAsset] - Bad idOrKey") end end diff --git a/src/gameproductservice/src/Shared/Ownership/WellKnownAssetOwnershipHandler.lua b/src/gameproductservice/src/Shared/Ownership/WellKnownAssetOwnershipHandler.lua index 283b18f8fc..c96da2649e 100644 --- a/src/gameproductservice/src/Shared/Ownership/WellKnownAssetOwnershipHandler.lua +++ b/src/gameproductservice/src/Shared/Ownership/WellKnownAssetOwnershipHandler.lua @@ -46,16 +46,31 @@ function WellKnownAssetOwnershipHandler.new(adornee, gameConfigAsset) return self end +--[=[ + Sets if the asset is owned + + @param isOwned boolean +]=] function WellKnownAssetOwnershipHandler:SetIsOwned(isOwned) assert(type(isOwned) == "boolean", "Bad isOwned") self._isOwned.Value = isOwned end +--[=[ + Gets if the asset is owned + + @return boolean +]=] function WellKnownAssetOwnershipHandler:GetIsOwned() return self._isOwned.Value end +--[=[ + Observes if the asset is owned + + @return Observable +]=] function WellKnownAssetOwnershipHandler:ObserveIsOwned() return self._isOwned:Observe() end diff --git a/src/gameproductservice/src/Shared/Trackers/PlayerAssetMarketTracker.lua b/src/gameproductservice/src/Shared/Trackers/PlayerAssetMarketTracker.lua index 5415665fb9..3c6d1e0b5f 100644 --- a/src/gameproductservice/src/Shared/Trackers/PlayerAssetMarketTracker.lua +++ b/src/gameproductservice/src/Shared/Trackers/PlayerAssetMarketTracker.lua @@ -7,18 +7,30 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") -local Signal = require("Signal") +local GameConfigAssetTypeUtils = require("GameConfigAssetTypeUtils") +local Maid = require("Maid") +local Observable = require("Observable") local Promise = require("Promise") +local Signal = require("Signal") local PlayerAssetMarketTracker = setmetatable({}, BaseObject) PlayerAssetMarketTracker.ClassName = "PlayerAssetMarketTracker" PlayerAssetMarketTracker.__index = PlayerAssetMarketTracker -function PlayerAssetMarketTracker.new(assetType, convertIds) +--[=[ + @param assetType GameConfigAssetTypes + @param convertIds function + @param observeIdsBrio function + @return PlayerAssetMarketTracker +]=] +function PlayerAssetMarketTracker.new(assetType, convertIds, observeIdsBrio) + assert(GameConfigAssetTypeUtils.isAssetType(assetType), "Bad assetType") + local self = setmetatable(BaseObject.new(), PlayerAssetMarketTracker) self._assetType = assert(assetType, "No assetType") self._convertIds = assert(convertIds, "No convertIds") + self._observeIdsBrio = assert(observeIdsBrio, "No observeIdsBrio") self._pendingPromises = {} -- { [number] = Promise } self._purchasedThisSession = {} -- [number] = true @@ -47,6 +59,47 @@ function PlayerAssetMarketTracker.new(assetType, convertIds) return self end +--[=[ + Observes an asset purchased + + @param idOrKey string | number + @return Observable<()> +]=] +function PlayerAssetMarketTracker:ObserveAssetPurchased(idOrKey) + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return Observable.new(function(sub) + local topMaid = Maid.new() + local knownIds = {} + + topMaid:GiveTask(self._observeIdsBrio(idOrKey):Subscribe(function(brio) + if brio:IsDead() then + return + end + + local maid = brio:ToMaid() + local id = brio:GetValue() + + knownIds[id] = (knownIds[id] or 0) + 1 + + maid:GiveTask(function() + knownIds[id] = (knownIds[id] or 0) - 1 + if knownIds[id] <= 0 then + knownIds[id] = nil + end + end) + end)) + + topMaid:GiveTask(self.Purchased:Connect(function(purchasedId) + if knownIds[purchasedId] then + sub:Fire() + end + end)) + + return topMaid + end) +end + --[=[ Prompts the player to purchase a the asset and returns a tracking promise which will resolve with the purchase state @@ -84,6 +137,9 @@ function PlayerAssetMarketTracker:PromisePromptPurchase(idOrKey) self._promptsOpen.Value = self._promptsOpen.Value + 1 promise:Finally(function() + if self._pendingPromises[id] == promise then + self._pendingPromises[id] = nil + end self._promptsOpen.Value = self._promptsOpen.Value - 1 end) @@ -93,6 +149,11 @@ function PlayerAssetMarketTracker:PromisePromptPurchase(idOrKey) end) end +--[=[ + Sets the ownership tracker for this asset tracker + + @param ownershipTracker PlayerAssetOwnershipTracker +]=] function PlayerAssetMarketTracker:SetOwnershipTracker(ownershipTracker) assert(type(ownershipTracker) == "table" or ownershipTracker == nil, "Bad ownershipTracker") @@ -121,6 +182,11 @@ function PlayerAssetMarketTracker:HasPurchasedThisSession(idOrKey) return false end +--[=[ + Returns true if a prompt is open for the asset + + @return boolean +]=] function PlayerAssetMarketTracker:IsPromptOpen() return self._promptsOpen.Value > 0 end @@ -151,16 +217,21 @@ function PlayerAssetMarketTracker:_handlePurchaseEvent(id, isPurchased, isFromRe end end - if promise then - self._pendingPromises[id] = nil - end - if isPurchased then self._purchasedThisSession[id] = true - self.Purchased:Fire(id) + + if self._receiptProcessingExpected then + if isFromReceipt then + self.Purchased:Fire(id) + end + else + self.Purchased:Fire(id) + end end - self.PromptFinished:Fire(id, isPurchased) + if not isFromReceipt then + self.PromptFinished:Fire(id, isPurchased) + end if promise then task.spawn(function() @@ -169,23 +240,38 @@ function PlayerAssetMarketTracker:_handlePurchaseEvent(id, isPurchased, isFromRe end end +--[=[ + Sets if this tracker is handling purchase receipts as a more authenticated mechanism + + @param receiptProcessingExpected boolean +]=] function PlayerAssetMarketTracker:SetReceiptProcessingExpected(receiptProcessingExpected) assert(type(receiptProcessingExpected) == "boolean", "Bad receiptProcessingExpected") self._receiptProcessingExpected = receiptProcessingExpected end -function PlayerAssetMarketTracker:HandleProcessReceipt(_player, receiptInfo) - assert(self._receiptProcessingExpected, "No receiptProcessingExpected") +--[=[ + Gets if this tracker is handling purchase receipts as a more authenticated mechanism - local productId = receiptInfo.ProductId - local pendingForAssetId = self._pendingPromises[productId] + @return boolean +]=] +function PlayerAssetMarketTracker:GetReceiptProcessingExpected() + return self._receiptProcessingExpected +end - if pendingForAssetId then - self:_handlePurchaseEvent(productId, true, true) +--[=[ + Handles the receipt processing - return Enum.ProductPurchaseDecision.PurchaseGranted - end + @param player Player + @param receiptInfo ReceiptInfo + @return ProductPurchaseDecision +]=] +function PlayerAssetMarketTracker:HandleProcessReceipt(player, receiptInfo) + assert(typeof(player) == "Instance", "Bad player") + assert(self._receiptProcessingExpected, "No receiptProcessingExpected") + + self:_handlePurchaseEvent(receiptInfo.ProductId, true, true) -- Always grant... return Enum.ProductPurchaseDecision.PurchaseGranted diff --git a/src/gameproductservice/src/Shared/Trackers/PlayerMarketeer.lua b/src/gameproductservice/src/Shared/Trackers/PlayerMarketeer.lua index 485b44349e..35851759bf 100644 --- a/src/gameproductservice/src/Shared/Trackers/PlayerMarketeer.lua +++ b/src/gameproductservice/src/Shared/Trackers/PlayerMarketeer.lua @@ -87,6 +87,11 @@ function PlayerMarketeer:IsOwnable(assetType) return self._ownershipTrackers[assetType] ~= nil end +--[=[ + Returns true if any prompt is open + + @return boolean +]=] function PlayerMarketeer:IsPromptOpen() for _, assetTracker in pairs(self._assetMarketTrackers) do if assetTracker:IsPromptOpen() then @@ -99,6 +104,7 @@ end --[=[ Gets the current asset tracker + @param assetType GameConfigAssetType @return PlayerAssetMarketTracker ]=] @@ -114,6 +120,7 @@ end --[=[ Gets the current asset tracker + @param assetType GameConfigAssetType @return PlayerAssetMarketTracker ]=] @@ -151,6 +158,10 @@ function PlayerMarketeer:_addAssetTracker(assetType) assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") return self._configPicker:ToAssetId(assetType, idOrKey) + end, function(idOrKey) + assert(type(idOrKey) == "number" or type(idOrKey) == "string", "Bad idOrKey") + + return self._configPicker:ObserveToAssetIdBrio(assetType, idOrKey) end) self._maid:GiveTask(assetMarketTracker) diff --git a/src/influxdbclient/src/Shared/Write/InfluxDBPoint.lua b/src/influxdbclient/src/Shared/Write/InfluxDBPoint.lua index 7334a9de8c..3702c7e8d5 100644 --- a/src/influxdbclient/src/Shared/Write/InfluxDBPoint.lua +++ b/src/influxdbclient/src/Shared/Write/InfluxDBPoint.lua @@ -204,11 +204,7 @@ function InfluxDBPoint:ToLineProtocol(pointSettings) local defaultTags = pointSettings:GetDefaultTags() if next(defaultTags) or next(self._tags) then - local tagKeysSet = {} - - for key, value in pairs(self._tags) do - tagKeysSet[key] = value - end + local tagKeysSet = table.clone(self._tags) for key, value in pairs(defaultTags) do tagKeysSet[key] = value end diff --git a/src/loader/src/Utils.lua b/src/loader/src/Utils.lua index be7754f917..70870360c4 100644 --- a/src/loader/src/Utils.lua +++ b/src/loader/src/Utils.lua @@ -18,14 +18,6 @@ function Utils.readonly(_table) return setmetatable(_table, READ_ONLY_METATABLE) end -function Utils.copyTable(target) - local new = {} - for key, value in pairs(target) do - new[key] = value - end - return new -end - function Utils.count(_table) local count = 0 for _, _ in pairs(_table) do diff --git a/src/loader/src2/Maid.lua b/src/loader/src2/Maid.lua index 097f0d9f4d..f3e3122181 100644 --- a/src/loader/src2/Maid.lua +++ b/src/loader/src2/Maid.lua @@ -135,6 +135,26 @@ function Maid:__newindex(index, newTask) end end +--[=[ + Gives a task to the maid for cleanup and returnsthe resulting value + + @param task MaidTask -- An item to clean + @return MaidTask +]=] +function Maid:Add(task) + if not task then + error("Task cannot be false or nil", 2) + end + + self[#self._tasks+1] = task + + if type(task) == "table" and (not task.Destroy) then + warn("[Maid.GiveTask] - Gave table task without .Destroy\n\n" .. debug.traceback()) + end + + return task +end + --[=[ Gives a task to the maid for cleanup, but uses an incremented number as a key. diff --git a/src/loader/src2/Utils.lua b/src/loader/src2/Utils.lua index be7754f917..70870360c4 100644 --- a/src/loader/src2/Utils.lua +++ b/src/loader/src2/Utils.lua @@ -18,14 +18,6 @@ function Utils.readonly(_table) return setmetatable(_table, READ_ONLY_METATABLE) end -function Utils.copyTable(target) - local new = {} - for key, value in pairs(target) do - new[key] = value - end - return new -end - function Utils.count(_table) local count = 0 for _, _ in pairs(_table) do diff --git a/src/maid/src/Shared/Maid.lua b/src/maid/src/Shared/Maid.lua index 097f0d9f4d..f3e3122181 100644 --- a/src/maid/src/Shared/Maid.lua +++ b/src/maid/src/Shared/Maid.lua @@ -135,6 +135,26 @@ function Maid:__newindex(index, newTask) end end +--[=[ + Gives a task to the maid for cleanup and returnsthe resulting value + + @param task MaidTask -- An item to clean + @return MaidTask +]=] +function Maid:Add(task) + if not task then + error("Task cannot be false or nil", 2) + end + + self[#self._tasks+1] = task + + if type(task) == "table" and (not task.Destroy) then + warn("[Maid.GiveTask] - Gave table task without .Destroy\n\n" .. debug.traceback()) + end + + return task +end + --[=[ Gives a task to the maid for cleanup, but uses an incremented number as a key. diff --git a/src/math/src/Shared/Math.lua b/src/math/src/Shared/Math.lua index 5bc77ba164..3dd25412b9 100644 --- a/src/math/src/Shared/Math.lua +++ b/src/math/src/Shared/Math.lua @@ -38,10 +38,12 @@ end @param average number @param spread number + @param randomValue number? @return number ]=] -function Math.jitter(average: number, spread: number) - return average - 0.5*spread + math.random()*spread +function Math.jitter(average: number, spread: number, randomValue) + randomValue = randomValue or math.random() + return average - 0.5*spread + randomValue*spread end --[=[ diff --git a/src/nocollisionconstraintutils/src/Shared/NoCollisionConstraintUtils.lua b/src/nocollisionconstraintutils/src/Shared/NoCollisionConstraintUtils.lua index ca9cdd8e68..70740bd713 100644 --- a/src/nocollisionconstraintutils/src/Shared/NoCollisionConstraintUtils.lua +++ b/src/nocollisionconstraintutils/src/Shared/NoCollisionConstraintUtils.lua @@ -40,7 +40,7 @@ end @return Maid ]=] function NoCollisionConstraintUtils.tempNoCollision(parts0, parts1, parent) - assert(typeof(parent) == "Instance" or type(parent) == "boolean" or type(parent) == nil, "Bad parent") + assert(typeof(parent) == "Instance" or type(parent) == "boolean" or type(parent) == "nil", "Bad parent") local maid = Maid.new() @@ -62,7 +62,7 @@ end function NoCollisionConstraintUtils.createBetweenPartsLists(parts0, parts1, parent) assert(type(parts0) == "table", "Bad parts0") assert(type(parts1) == "table", "Bad parts1") - assert(typeof(parent) == "Instance" or type(parent) == "boolean" or type(parent) == nil, "Bad parent") + assert(typeof(parent) == "Instance" or type(parent) == "boolean" or type(parent) == "nil", "Bad parent") local collisionConstraints = {} diff --git a/src/observablecollection/src/Shared/ObservableMap.lua b/src/observablecollection/src/Shared/ObservableMap.lua index a99ebb0951..37d47775df 100644 --- a/src/observablecollection/src/Shared/ObservableMap.lua +++ b/src/observablecollection/src/Shared/ObservableMap.lua @@ -5,10 +5,11 @@ local require = require(script.Parent.loader).load(script) -local Signal = require("Signal") -local Observable = require("Observable") -local Maid = require("Maid") local Brio = require("Brio") +local Maid = require("Maid") +local Observable = require("Observable") +local ObservableSubscriptionTable = require("ObservableSubscriptionTable") +local Signal = require("Signal") local ValueObject = require("ValueObject") local ObservableMap = {} @@ -25,7 +26,8 @@ function ObservableMap.new() self._maid = Maid.new() self._map = {} - self._keyToSubList = {} + self._keySubTable = ObservableSubscriptionTable.new() + self._maid:GiveTask(self._keySubTable) self._countValue = ValueObject.new(0, "number") self._maid:GiveTask(self._countValue) @@ -182,32 +184,8 @@ end function ObservableMap:ObserveValueForKey(key) assert(key ~= nil, "Bad key") - return Observable.new(function(sub) - local maid = Maid.new() - - if not self._keyToSubList[key] then - self._keyToSubList[key] = {} - end - table.insert(self._keyToSubList[key], sub) - - maid:GiveTask(function() - local subsList = self._keyToSubList[key] - if subsList then - local index = table.find(subsList, sub) - - if index then - table.remove(subsList, index) - end - - if #subsList == 0 then - self._keyToSubList[key] = nil - end - end - end) - + return self._keySubTable:Observe(key, function(sub) sub:Fire(self._map[key]) - - return maid end) end @@ -239,17 +217,7 @@ function ObservableMap:Set(key, value) end self.KeyValueChanged:Fire(key, value, oldValue) - - local subList = self._keyToSubList[key] - if subList then - for _, sub in pairs(table.clone(subList)) do - if sub and sub:IsPending() then - task.spawn(function() - sub:Fire(value) - end) - end - end - end + self._keySubTable:Fire(key, value) return self:_getRemovalCallback(key, value) end diff --git a/src/permissionprovider/package.json b/src/permissionprovider/package.json index 76b3644284..bad48aa9ce 100644 --- a/src/permissionprovider/package.json +++ b/src/permissionprovider/package.json @@ -26,11 +26,14 @@ ], "dependencies": { "@quenty/baseobject": "file:../baseobject", + "@quenty/brio": "file:../brio", "@quenty/grouputils": "file:../grouputils", "@quenty/loader": "file:../loader", "@quenty/maid": "file:../maid", + "@quenty/playerutils": "file:../playerutils", "@quenty/promise": "file:../promise", "@quenty/remoting": "file:../remoting", + "@quenty/rx": "file:../rx", "@quenty/servicebag": "file:../servicebag", "@quenty/table": "file:../table" }, diff --git a/src/permissionprovider/src/Server/PermissionService.lua b/src/permissionprovider/src/Server/PermissionService.lua index 723c3b966f..5a6ed747e8 100644 --- a/src/permissionprovider/src/Server/PermissionService.lua +++ b/src/permissionprovider/src/Server/PermissionService.lua @@ -26,10 +26,15 @@ local require = require(script.Parent.loader).load(script) local CreatorPermissionProvider = require("CreatorPermissionProvider") local GroupPermissionProvider = require("GroupPermissionProvider") +local Maid = require("Maid") +local PermissionLevel = require("PermissionLevel") local PermissionProviderConstants = require("PermissionProviderConstants") local PermissionProviderUtils = require("PermissionProviderUtils") local Promise = require("Promise") -local Maid = require("Maid") +local Rx = require("Rx") +local RxBrioUtils = require("RxBrioUtils") +local RxPlayerUtils = require("RxPlayerUtils") +local PermissionLevelUtils = require("PermissionLevelUtils") local PermissionService = {} PermissionService.ServiceName = "PermissionService" @@ -100,10 +105,7 @@ end function PermissionService:PromiseIsAdmin(player) assert(typeof(player) == "Instance" and player:IsA("Player"), "bad player") - return self:PromisePermissionProvider() - :Then(function(permissionProvider) - return permissionProvider:PromiseIsAdmin(player) - end) + return self:PromiseIsPermissionLevel(player, PermissionLevel.ADMIN) end --[=[ @@ -114,12 +116,50 @@ end function PermissionService:PromiseIsCreator(player) assert(typeof(player) == "Instance" and player:IsA("Player"), "bad player") + return self:PromiseIsPermissionLevel(player, PermissionLevel.CREATOR) +end + +--[=[ + Returns whether the player is a creator. + @param player Player + @param permissionLevel PermissionLevel + @return Promise +]=] +function PermissionService:PromiseIsPermissionLevel(player, permissionLevel) + assert(typeof(player) == "Instance" and player:IsA("Player"), "bad player") + assert(PermissionLevelUtils.isPermissionLevel(permissionLevel), "Bad permissionLevel") + return self:PromisePermissionProvider() :Then(function(permissionProvider) - return permissionProvider:PromiseIsCreator(player) + return permissionProvider:PromiseIsPermissionLevel(player, permissionLevel) end) end +--[=[ + Observe all creators in the game + + @param permissionLevel PermissionLevel + @return Observable> +]=] +function PermissionService:ObservePermissionedPlayersBrio(permissionLevel) + assert(PermissionLevelUtils.isPermissionLevel(permissionLevel), "Bad permissionLevel") + + return RxPlayerUtils.observePlayersBrio():Pipe({ + RxBrioUtils.flatMapBrio(function(player) + return Rx.fromPromise(self:PromiseIsPermissionLevel(player, permissionLevel)) + :Pipe({ + Rx.switchMap(function(hasPermission) + if hasPermission then + return Rx.of(player) + else + return Rx.EMPTY + end + end) + }) + end); + }) +end + function PermissionService:Destroy() self._maid:DoCleaning() self._provider = nil diff --git a/src/permissionprovider/src/Server/Providers/BasePermissionProvider.lua b/src/permissionprovider/src/Server/Providers/BasePermissionProvider.lua index d59032435e..f7f4bb2738 100644 --- a/src/permissionprovider/src/Server/Providers/BasePermissionProvider.lua +++ b/src/permissionprovider/src/Server/Providers/BasePermissionProvider.lua @@ -8,6 +8,8 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") local GetRemoteFunction = require("GetRemoteFunction") +local PermissionLevel = require("PermissionLevel") +local PermissionLevelUtils = require("PermissionLevelUtils") local Table = require("Table") local BasePermissionProvider = setmetatable({}, BaseObject) @@ -44,14 +46,51 @@ end --[=[ Returns whether the player is a creator. @param player Player + @param permissionLevel PermissionLevel @return Promise ]=] -function BasePermissionProvider:PromiseIsCreator(player) +function BasePermissionProvider:PromiseIsPermissionLevel(player, permissionLevel) assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + assert(PermissionLevelUtils.isPermissionLevel(permissionLevel), "Bad permissionLevel") error("Not implemented") end +--[=[ + Returns whether the player is a creator. + @param player Player + @param permissionLevel PermissionLevel + @return Promise +]=] +function BasePermissionProvider:IsPermissionLevel(player, permissionLevel) + assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + assert(PermissionLevelUtils.isPermissionLevel(permissionLevel), "Bad permissionLevel") + + local promise = self:PromiseIsPermissionLevel(player, permissionLevel) + if promise:IsPending() then + return false -- We won't yield for this + end + + local ok, result = promise:Yield() + if not ok then + warn("[BasePermissionProvider] - %s"):format(tostring(result)) + return false + end + + return result +end + +--[=[ + Returns whether the player is a creator. + @param player Player + @return Promise +]=] +function BasePermissionProvider:PromiseIsCreator(player) + assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + + return self:PromiseIsPermissionLevel(player, PermissionLevel.CREATOR) +end + --[=[ Returns whether the player is an admin. @param player Player @@ -60,7 +99,7 @@ end function BasePermissionProvider:PromiseIsAdmin(player) assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - error("Not implemented") + return self:PromiseIsPermissionLevel(player, PermissionLevel.ADMIN) end --[=[ @@ -76,18 +115,7 @@ end function BasePermissionProvider:IsCreator(player) assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - local promise = self:PromiseIsCreator(player) - if promise:IsPending() then - return false -- We won't yield for this - end - - local ok, result = promise:Yield() - if not ok then - warn("[BasePermissionProvider] - %s"):format(tostring(result)) - return false - end - - return result + return self:IsCreator(player, PermissionLevel.CREATOR) end --[=[ @@ -103,18 +131,7 @@ end function BasePermissionProvider:IsAdmin(player) assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - local promise = self:PromiseIsAdmin(player) - if promise:IsPending() then - return false -- We won't yield for this - end - - local ok, result = promise:Yield() - if not ok then - warn("[BasePermissionProvider] - %s"):format(tostring(result)) - return false - end - - return result + return self:IsPermissionLevel(player, PermissionLevel.ADMIN) end function BasePermissionProvider:_onServerInvoke(player) diff --git a/src/permissionprovider/src/Server/Providers/CreatorPermissionProvider.lua b/src/permissionprovider/src/Server/Providers/CreatorPermissionProvider.lua index 18d2b8a37a..63254d10da 100644 --- a/src/permissionprovider/src/Server/Providers/CreatorPermissionProvider.lua +++ b/src/permissionprovider/src/Server/Providers/CreatorPermissionProvider.lua @@ -12,6 +12,8 @@ local RunService = game:GetService("RunService") local BasePermissionProvider = require("BasePermissionProvider") local PermissionProviderConstants = require("PermissionProviderConstants") local Promise = require("Promise") +local PermissionLevel = require("PermissionLevel") +local PermissionLevelUtils = require("PermissionLevelUtils") local CreatorPermissionProvider = setmetatable({}, BasePermissionProvider) CreatorPermissionProvider.ClassName = "CreatorPermissionProvider" @@ -31,23 +33,22 @@ function CreatorPermissionProvider.new(config) end --[=[ - Returns whether the player is a creator. - @param player Player - @return Promise -]=] -function CreatorPermissionProvider:PromiseIsCreator(player) - return Promise.resolved(player.UserId == self._userId - or RunService:IsStudio()) -end + Returns whether the player is at a specific permission level. ---[=[ - Returns whether the player is an admin. @param player Player + @param permissionLevel PermissionLevel @return Promise ]=] -function CreatorPermissionProvider:PromiseIsAdmin(player) - return Promise.resolved(player.UserId == self._userId - or RunService:IsStudio()) +function CreatorPermissionProvider:PromiseIsPermissionLevel(player, permissionLevel) + assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + assert(PermissionLevelUtils.isPermissionLevel(permissionLevel), "Bad permissionLevel") + + if permissionLevel == PermissionLevel.ADMIN + or permissionLevel == PermissionLevel.CREATOR then + return Promise.resolved(player.UserId == self._userId or RunService:IsStudio()) + else + error("Unknown permissionLevel") + end end return CreatorPermissionProvider \ No newline at end of file diff --git a/src/permissionprovider/src/Server/Providers/GroupPermissionProvider.lua b/src/permissionprovider/src/Server/Providers/GroupPermissionProvider.lua index eb5d3207c0..5f341a6178 100644 --- a/src/permissionprovider/src/Server/Providers/GroupPermissionProvider.lua +++ b/src/permissionprovider/src/Server/Providers/GroupPermissionProvider.lua @@ -9,10 +9,12 @@ local require = require(script.Parent.loader).load(script) local Players = game:GetService("Players") -local PermissionProviderConstants = require("PermissionProviderConstants") -local Promise = require("Promise") local BasePermissionProvider = require("BasePermissionProvider") local GroupUtils = require("GroupUtils") +local PermissionLevel = require("PermissionLevel") +local PermissionLevelUtils = require("PermissionLevelUtils") +local PermissionProviderConstants = require("PermissionProviderConstants") +local Promise = require("Promise") local GroupPermissionProvider = setmetatable({}, BasePermissionProvider) GroupPermissionProvider.__index = GroupPermissionProvider @@ -68,11 +70,26 @@ function GroupPermissionProvider:Start() end --[=[ - Returns whether the player is a creator. + Returns whether the player is at a specific permission level + @param player Player + @param permissionLevel PermissionLevel @return Promise ]=] -function GroupPermissionProvider:PromiseIsCreator(player) +function GroupPermissionProvider:PromiseIsPermissionLevel(player, permissionLevel) + assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + assert(PermissionLevelUtils.isPermissionLevel(permissionLevel), "Bad permissionLevel") + + if permissionLevel == PermissionLevel.ADMIN then + return self:_promiseIsAdmin(player) + elseif permissionLevel == PermissionLevel.CREATOR then + return self:_promiseIsCreator(player) + else + error("Unknown permissionLevel") + end +end + +function GroupPermissionProvider:_promiseIsCreator(player) assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") assert(player:IsDescendantOf(game), "Bad player") @@ -86,12 +103,7 @@ function GroupPermissionProvider:PromiseIsCreator(player) end) end ---[=[ - Returns whether the player is an admin. - @param player Player - @return Promise -]=] -function GroupPermissionProvider:PromiseIsAdmin(player) +function GroupPermissionProvider:_promiseIsAdmin(player) assert(player:IsDescendantOf(game)) -- really not saving much time. diff --git a/src/permissionprovider/src/Shared/PermissionLevel.lua b/src/permissionprovider/src/Shared/PermissionLevel.lua new file mode 100644 index 0000000000..259da8c0e4 --- /dev/null +++ b/src/permissionprovider/src/Shared/PermissionLevel.lua @@ -0,0 +1,12 @@ +--[=[ + @class PermissionLevel +]=] + +local require = require(script.Parent.loader).load(script) + +local Table = require("Table") + +return Table.readonly({ + ADMIN = "admin"; + CREATOR = "creator"; +}) \ No newline at end of file diff --git a/src/permissionprovider/src/Shared/PermissionLevelUtils.lua b/src/permissionprovider/src/Shared/PermissionLevelUtils.lua new file mode 100644 index 0000000000..96726dbdb9 --- /dev/null +++ b/src/permissionprovider/src/Shared/PermissionLevelUtils.lua @@ -0,0 +1,20 @@ +--[=[ + @class PermissionLevelUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local PermissionLevel = require("PermissionLevel") + +local PermissionLevelUtils = {} + +local ALLOWED = {} +for _, item in pairs(PermissionLevel) do + ALLOWED[item] = true +end + +function PermissionLevelUtils.isPermissionLevel(permissionLevel) + return ALLOWED[permissionLevel] +end + +return PermissionLevelUtils \ No newline at end of file diff --git a/src/pillbacking/src/Client/PillBackingBuilder.lua b/src/pillbacking/src/Client/PillBackingBuilder.lua index 461525d38a..51a50aec51 100644 --- a/src/pillbacking/src/Client/PillBackingBuilder.lua +++ b/src/pillbacking/src/Client/PillBackingBuilder.lua @@ -3,10 +3,6 @@ @class PillBackingBuilder ]=] -local require = require(script.Parent.loader).load(script) - -local Table = require("Table") - local PillBackingBuilder = {} PillBackingBuilder.__index = PillBackingBuilder PillBackingBuilder.ClassName = "PillBackingBuilder" @@ -342,7 +338,7 @@ end function PillBackingBuilder:_configureOptions(gui, options) assert(gui, "Must pass in GUI") - options = Table.copy(options or self._options) + options = table.clone(options or self._options) options.ZIndex = options.ZIndex or gui.ZIndex options.ShadowZIndex = options.ShadowZIndex or options.ZIndex - 1 options.BackgroundColor3 = options.BackgroundColor3 or gui.BackgroundColor3 diff --git a/src/playerhumanoidbinder/package.json b/src/playerhumanoidbinder/package.json index 40fa25290c..8bc4878297 100644 --- a/src/playerhumanoidbinder/package.json +++ b/src/playerhumanoidbinder/package.json @@ -28,7 +28,8 @@ "@quenty/binder": "file:../binder", "@quenty/humanoidtracker": "file:../humanoidtracker", "@quenty/loader": "file:../loader", - "@quenty/maid": "file:../maid" + "@quenty/maid": "file:../maid", + "@quenty/valueobject": "file:../valueobject" }, "publishConfig": { "access": "public" diff --git a/src/playerhumanoidbinder/src/Server/PlayerCharacterBinder.lua b/src/playerhumanoidbinder/src/Server/PlayerCharacterBinder.lua index 0e2fe2d034..75166f858e 100644 --- a/src/playerhumanoidbinder/src/Server/PlayerCharacterBinder.lua +++ b/src/playerhumanoidbinder/src/Server/PlayerCharacterBinder.lua @@ -9,6 +9,7 @@ local Players = game:GetService("Players") local Binder = require("Binder") local Maid = require("Maid") +local ValueObject = require("ValueObject") local PlayerCharacterBinder = setmetatable({}, Binder) PlayerCharacterBinder.ClassName = "PlayerCharacterBinder" @@ -37,8 +38,7 @@ function PlayerCharacterBinder:Init(...) getmetatable(PlayerCharacterBinder).Init(self, ...) if not self._shouldTag then - self._shouldTag = Instance.new("BoolValue") - self._shouldTag.Value = true + self._shouldTag = ValueObject.new(true, "boolean") self._maid:GiveTask(self._shouldTag) end end @@ -54,6 +54,21 @@ function PlayerCharacterBinder:SetAutomaticTagging(shouldTag) self._shouldTag.Value = shouldTag end +--[=[ + @return Observable +]=] +function PlayerCharacterBinder:ObserveAutomaticTagging() + return self._shouldTag:Observe() +end + +--[=[ + @param predicate function -- Optional predicate + @return Observable> +]=] +function PlayerCharacterBinder:ObserveAutomaticTaggingBrio(predicate) + return self._shouldTag:ObserveBrio(predicate) +end + --[=[ Starts the binder. See [Binder.Start]. Should be done via a [ServiceBag]. diff --git a/src/playerhumanoidbinder/src/Server/PlayerHumanoidBinder.lua b/src/playerhumanoidbinder/src/Server/PlayerHumanoidBinder.lua index f2ece67ce6..f76ec2a823 100644 --- a/src/playerhumanoidbinder/src/Server/PlayerHumanoidBinder.lua +++ b/src/playerhumanoidbinder/src/Server/PlayerHumanoidBinder.lua @@ -10,6 +10,7 @@ local Players = game:GetService("Players") local Binder = require("Binder") local Maid = require("Maid") local HumanoidTracker = require("HumanoidTracker") +local ValueObject = require("ValueObject") local PlayerHumanoidBinder = setmetatable({}, Binder) PlayerHumanoidBinder.ClassName = "PlayerHumanoidBinder" @@ -38,8 +39,7 @@ function PlayerHumanoidBinder:Init(...) getmetatable(PlayerHumanoidBinder).Init(self, ...) if not self._shouldTag then - self._shouldTag = Instance.new("BoolValue") - self._shouldTag.Value = true + self._shouldTag = ValueObject.new(true, "boolean") self._maid:GiveTask(self._shouldTag) end end @@ -55,6 +55,21 @@ function PlayerHumanoidBinder:SetAutomaticTagging(shouldTag) self._shouldTag.Value = shouldTag end +--[=[ + @return Observable +]=] +function PlayerHumanoidBinder:ObserveAutomaticTagging() + return self._shouldTag:Observe() +end + +--[=[ + @param predicate function -- Optional predicate + @return Observable> +]=] +function PlayerHumanoidBinder:ObserveAutomaticTaggingBrio(predicate) + return self._shouldTag:ObserveBrio(predicate) +end + --[=[ Starts the binder. See [Binder.Start]. Should be done via a [ServiceBag]. diff --git a/src/promise/src/Shared/Promise.lua b/src/promise/src/Shared/Promise.lua index 1ae4db84ec..6a9943e773 100644 --- a/src/promise/src/Shared/Promise.lua +++ b/src/promise/src/Shared/Promise.lua @@ -53,7 +53,7 @@ function Promise.new(func) end --[=[ - Initializes a new promise with the given function in a deferred wrapper. + Initializes a new promise with the given function in a spawn wrapper. @param func (resolve: (...) -> (), reject: (...) -> ()) -> ()? @return Promise @@ -66,6 +66,24 @@ function Promise.spawn(func) return self end +--[=[ + Initializes a new promise with the given function in a delay wrapper. + + @param seconds number + @param func (resolve: (...) -> (), reject: (...) -> ()) -> ()? + @return Promise +]=] +function Promise.delay(seconds, func) + assert(type(seconds) == "number", "Bad seconds") + assert(type(func) == "function", "Bad func") + + local self = Promise.new() + + task.delay(seconds, func, self:_getResolveReject()) + + return self +end + --[=[ Initializes a new promise with the given function in a deferred wrapper. diff --git a/src/promise/src/Shared/PromiseUtils.lua b/src/promise/src/Shared/PromiseUtils.lua index 548e545ed3..4d14f3ac0a 100644 --- a/src/promise/src/Shared/PromiseUtils.lua +++ b/src/promise/src/Shared/PromiseUtils.lua @@ -32,9 +32,17 @@ function PromiseUtils.any(promises) return returnPromise end +--[=[ + Returns a promise that will resolve after the set amount of seconds + + @param seconds number + @return Promise +]=] function PromiseUtils.delayed(seconds) - return Promise.spawn(function(resolve, _reject) - task.delay(seconds, resolve) + assert(type(seconds) == "number", "Bad seconds") + + return Promise.delay(seconds, function(resolve, _reject) + resolve() end) end diff --git a/src/randomutils/package.json b/src/randomutils/package.json index 8a6664be0d..8a2204c5db 100644 --- a/src/randomutils/package.json +++ b/src/randomutils/package.json @@ -27,5 +27,8 @@ ], "publishConfig": { "access": "public" + }, + "dependencies": { + "@quenty/loader": "file:../loader" } } diff --git a/src/randomutils/src/Shared/RandomSampler.lua b/src/randomutils/src/Shared/RandomSampler.lua new file mode 100644 index 0000000000..145c10d84b --- /dev/null +++ b/src/randomutils/src/Shared/RandomSampler.lua @@ -0,0 +1,62 @@ +--[=[ + @class RandomSampler +]=] + +local require = require(script.Parent.loader).load(script) + +local RandomUtils = require("RandomUtils") + +local RandomSampler = {} +RandomSampler.ClassName = "RandomSampler" +RandomSampler.__index = RandomSampler + +function RandomSampler.new(samples) + local self = setmetatable({}, RandomSampler) + + self._optionsList = {} + self._shuffledAvailableList = {} + + if samples then + self:SetSamples(samples) + end + + return self +end + +function RandomSampler:SetSamples(samples) + assert(type(samples) == "table", "Bad samples") + + if self._optionsList ~= samples then + self._optionsList = samples + + -- TODO: Smarter refill + self:Refill() + end +end + +function RandomSampler:Sample() + if #self._shuffledAvailableList == 0 then + self:Refill() + end + + local selection = table.remove(self._shuffledAvailableList) + self._lastSelection = selection + + return selection +end + +function RandomSampler:Refill() + local newList = RandomUtils.shuffledCopy(self._optionsList) + + if #newList > 1 then + -- prevent repeat on restart + if newList[#newList] == self._lastSelection then + newList[1], newList[#newList] = newList[#newList], newList[1] + end + end + + self._shuffledAvailableList = newList +end + + +return RandomSampler \ No newline at end of file diff --git a/src/randomutils/src/Shared/RandomUtils.lua b/src/randomutils/src/Shared/RandomUtils.lua index ca8bf2fe07..534d59aece 100644 --- a/src/randomutils/src/Shared/RandomUtils.lua +++ b/src/randomutils/src/Shared/RandomUtils.lua @@ -64,10 +64,7 @@ end @return { T } ]=] function RandomUtils.shuffledCopy(list, random) - local copy = {} - for i=1, #list do - copy[i] = list[i] - end + local copy = table.clone(list) RandomUtils.shuffle(copy, random) diff --git a/src/randomutils/src/node_modules.project.json b/src/randomutils/src/node_modules.project.json new file mode 100644 index 0000000000..46233dac4f --- /dev/null +++ b/src/randomutils/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/src/raycaster/src/Shared/Raycaster.lua b/src/raycaster/src/Shared/Raycaster.lua index 14be59c078..731e4ebbf8 100644 --- a/src/raycaster/src/Shared/Raycaster.lua +++ b/src/raycaster/src/Shared/Raycaster.lua @@ -63,10 +63,7 @@ end function Raycaster:FindPartOnRay(ray) assert(typeof(ray) == "Ray", "Bad ray") - local ignoreList = {} - for key, value in pairs(rawget(self, "_ignoreList")) do - ignoreList[key] = value - end + local ignoreList = table.clone(rawget(self, "_ignoreList")) local casts = self.MaxCasts while casts > 0 do diff --git a/src/rbxthumb/src/node_modules.project.json b/src/rbxthumb/src/node_modules.project.json new file mode 100644 index 0000000000..46233dac4f --- /dev/null +++ b/src/rbxthumb/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/src/rigbuilderutils/src/Shared/RigBuilderUtils.lua b/src/rigbuilderutils/src/Shared/RigBuilderUtils.lua index 59e77b007d..5992f96a24 100644 --- a/src/rigbuilderutils/src/Shared/RigBuilderUtils.lua +++ b/src/rigbuilderutils/src/Shared/RigBuilderUtils.lua @@ -514,6 +514,12 @@ function RigBuilderUtils.promiseHumanoidModelFromUserId(userId, rigType, assetTy userId, rigType or Enum.HumanoidRigType.R15, assetTypeVerification or Enum.AssetTypeVerification.Default) + + for _, item in pairs(model:GetDescendants()) do + if item:IsA("LocalScript") then + item.Enabled = false + end + end end) if not ok then return reject(err or "Failed to create model") diff --git a/src/rodux-actions/src/Shared/RoduxActions.lua b/src/rodux-actions/src/Shared/RoduxActions.lua index 261a64add0..1f8971940d 100644 --- a/src/rodux-actions/src/Shared/RoduxActions.lua +++ b/src/rodux-actions/src/Shared/RoduxActions.lua @@ -48,7 +48,7 @@ function RoduxActions:CreateReducer(initialState, handlers) assert(validator:Validate(action)) - return handler(state, Table.readonly(Table.copy(action))) + return handler(state, Table.readonly(table.clone(action))) end return state diff --git a/src/rogue-humanoid/package.json b/src/rogue-humanoid/package.json index 51340634e0..eadf82e24f 100644 --- a/src/rogue-humanoid/package.json +++ b/src/rogue-humanoid/package.json @@ -29,6 +29,7 @@ "@quenty/baseobject": "file:../baseobject", "@quenty/binder": "file:../binder", "@quenty/brio": "file:../brio", + "@quenty/characterutils": "file:../characterutils", "@quenty/loader": "file:../loader", "@quenty/maid": "file:../maid", "@quenty/playerhumanoidbinder": "file:../playerhumanoidbinder", diff --git a/src/rogue-properties/package.json b/src/rogue-properties/package.json index f49f36228b..7817c1d0a7 100644 --- a/src/rogue-properties/package.json +++ b/src/rogue-properties/package.json @@ -27,6 +27,7 @@ "@quenty/baseobject": "file:../baseobject", "@quenty/binder": "file:../binder", "@quenty/brio": "file:../brio", + "@quenty/defaultvalueutils": "file:../defaultvalueutils", "@quenty/ducktype": "file:../ducktype", "@quenty/instanceutils": "file:../instanceutils", "@quenty/jsonutils": "file:../jsonutils", @@ -35,8 +36,10 @@ "@quenty/maid": "file:../maid", "@quenty/rx": "file:../rx", "@quenty/rxbinderutils": "file:../rxbinderutils", + "@quenty/rxsignal": "file:../rxsignal", "@quenty/servicebag": "file:../servicebag", "@quenty/signal": "file:../signal", + "@quenty/string": "file:../string", "@quenty/table": "file:../table", "@quenty/valuebaseutils": "file:../valuebaseutils", "@quenty/valueobject": "file:../valueobject", diff --git a/src/rogue-properties/src/Shared/Array/RoguePropertyArrayConstants.lua b/src/rogue-properties/src/Shared/Array/RoguePropertyArrayConstants.lua new file mode 100644 index 0000000000..a3eef8e667 --- /dev/null +++ b/src/rogue-properties/src/Shared/Array/RoguePropertyArrayConstants.lua @@ -0,0 +1,11 @@ +--[=[ + @class RoguePropertyArrayConstants +]=] + +local require = require(script.Parent.loader).load(script) + +local Table = require("Table") + +return Table.readonly({ + ARRAY_ENTRY_PERFIX = "ArrayEntry_"; +}) \ No newline at end of file diff --git a/src/rogue-properties/src/Shared/Array/RoguePropertyArrayUtils.lua b/src/rogue-properties/src/Shared/Array/RoguePropertyArrayUtils.lua new file mode 100644 index 0000000000..db81679a99 --- /dev/null +++ b/src/rogue-properties/src/Shared/Array/RoguePropertyArrayUtils.lua @@ -0,0 +1,160 @@ +--[=[ + @class RoguePropertyArrayUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local RoguePropertyArrayConstants = require("RoguePropertyArrayConstants") +local String = require("String") +local DefaultValueUtils = require("DefaultValueUtils") + +local RoguePropertyArrayUtils = {} + +function RoguePropertyArrayUtils.getNameFromIndex(index) + return RoguePropertyArrayConstants.ARRAY_ENTRY_PERFIX .. tostring(index) +end + +function RoguePropertyArrayUtils.getIndexFromName(name) + return tonumber(String.removePrefix(name, RoguePropertyArrayConstants.ARRAY_ENTRY_PERFIX)) +end + +function RoguePropertyArrayUtils.createRequiredPropertyDefinitionFromArray(arrayData, parentPropertyTableDefinition) + local expectedType = typeof(arrayData[1]) + if expectedType == "table" then + return RoguePropertyArrayUtils.createRequiredTableDefinition(arrayData, parentPropertyTableDefinition) + elseif expectedType == "nil" then + return nil, "Missing array data" + end + + for index, item in pairs(arrayData) do + if typeof(item) ~= expectedType then + expectedType = nil + -- TODO: Maybe union? + return nil, string.format("Expected type %q on %q, got %q", expectedType, tostring(index), typeof(item)) + end + end + + return RoguePropertyArrayUtils.createRequiredPropertyDefinitionFromType(expectedType, parentPropertyTableDefinition) +end + +function RoguePropertyArrayUtils.createRequiredTableDefinition(arrayData, parentPropertyTableDefinition) + local RoguePropertyTableDefinition = require("RoguePropertyTableDefinition") + + local entry = arrayData[1] + if type(entry) ~= "table" then + return nil, "Result was not a table" + end + + -- Check shared state against this... + local propertyDefinition = RoguePropertyTableDefinition.new() + propertyDefinition:SetName("") + propertyDefinition:SetParentPropertyTableDefinition(parentPropertyTableDefinition) + propertyDefinition:SetDefaultValue(DefaultValueUtils.toDefaultValue(entry)) + + for _, item in pairs(arrayData) do + local canAssign, message = propertyDefinition:CanAssign(item, true) + if not canAssign then + return nil, string.format("Cannot assign due to %q", message) + end + end + + return propertyDefinition +end + +function RoguePropertyArrayUtils.createRequiredPropertyDefinitionFromType(expectedType, parentPropertyTableDefinition) + local RoguePropertyDefinition = require("RoguePropertyDefinition") + + local default = DefaultValueUtils.getDefaultValueForType(expectedType) + if default == nil then + return nil, "Default value is nil" + end + + local propertyDefinition = RoguePropertyDefinition.new() + propertyDefinition:SetName("") + propertyDefinition:SetParentPropertyTableDefinition(parentPropertyTableDefinition) + propertyDefinition:SetDefaultValue(default) + + return propertyDefinition +end + +function RoguePropertyArrayUtils.createDefinitionsFromContainer(container, parentPropertyTableDefinition) + local RoguePropertyTableDefinition = require("RoguePropertyTableDefinition") + local RoguePropertyDefinition = require("RoguePropertyDefinition") + + local value = {} + + for _, item in pairs(container:GetChildren()) do + local index = RoguePropertyArrayUtils.getIndexFromName(item.Name) + if not index then + continue + end + + local definition + if item:IsA("Folder") then + definition = RoguePropertyTableDefinition.new(item.Name) + definition:SetName(item.Name) + definition:SetParentPropertyTableDefinition(parentPropertyTableDefinition) + definition:SetDefaultValue(RoguePropertyArrayUtils.getDefaultValueMapFromContainer(item)) + elseif item:IsA("ValueBase") then + definition = RoguePropertyDefinition.new() + definition:SetName(item.Name) + definition:SetParentPropertyTableDefinition(parentPropertyTableDefinition) + definition:SetDefaultValue(item.Value) + end + + value[index] = definition + end + + return value +end + +function RoguePropertyArrayUtils.getDefaultValueMapFromContainer(container) + local value = {} + + for _, item in pairs(container:GetChildren()) do + local index = RoguePropertyArrayUtils.getIndexFromName(item.Name) + if index then + if item:IsA("Folder") then + value[index] = RoguePropertyArrayUtils.getDefaultValueMapFromContainer(item) + elseif item:IsA("ValueBase") then + value[index] = item.Value + end + else + if item:IsA("Folder") then + value[item.Name] = RoguePropertyArrayUtils.getDefaultValueMapFromContainer(item) + else + value[item.Name] = item.Value + end + end + end + + return value +end + +function RoguePropertyArrayUtils.createDefinitionsFromArrayData(arrayData, propertyTableDefinition) + local RoguePropertyTableDefinition = require("RoguePropertyTableDefinition") + local RoguePropertyDefinition = require("RoguePropertyDefinition") + + local definitions = {} + for index, defaultValue in pairs(arrayData) do + local name = RoguePropertyArrayUtils.getNameFromIndex(index) + + if type(defaultValue) == "table" then + local tableDefinition = RoguePropertyTableDefinition.new() + tableDefinition:SetName(name) + tableDefinition:SetParentPropertyTableDefinition(propertyTableDefinition) + tableDefinition:SetDefaultValue(defaultValue) + definitions[index] = tableDefinition + else + local definition = RoguePropertyDefinition.new() + definition:SetName(name) + definition:SetParentPropertyTableDefinition(propertyTableDefinition) + definition:SetDefaultValue(defaultValue) + definitions[index] = definition + end + end + + return definitions +end + +return RoguePropertyArrayUtils \ No newline at end of file diff --git a/src/rogue-properties/src/Shared/Definition/RoguePropertyDefinition.lua b/src/rogue-properties/src/Shared/Definition/RoguePropertyDefinition.lua index 8065a792cd..6b60e31864 100644 --- a/src/rogue-properties/src/Shared/Definition/RoguePropertyDefinition.lua +++ b/src/rogue-properties/src/Shared/Definition/RoguePropertyDefinition.lua @@ -14,19 +14,21 @@ local RoguePropertyDefinition = {} RoguePropertyDefinition.ClassName = "RoguePropertyDefinition" RoguePropertyDefinition.__index = RoguePropertyDefinition -function RoguePropertyDefinition.new(name, defaultValue, roguePropertyTableDefinition) +function RoguePropertyDefinition.new() local self = setmetatable({}, RoguePropertyDefinition) + self._name = "Unnamed" + + return self +end + +function RoguePropertyDefinition:SetDefaultValue(defaultValue) assert(defaultValue ~= nil, "Bad defaultValue") - self._name = assert(name, "Bad name") self._defaultValue = defaultValue self._valueType = typeof(self._defaultValue) self._storageType = self:_computeStorageInstanceType() - self._roguePropertyTableDefinition = roguePropertyTableDefinition or nil self._encodedDefaultValue = RoguePropertyUtils.encodeProperty(self, self._defaultValue) - - return self end function RoguePropertyDefinition.isRoguePropertyDefinition(value) @@ -55,8 +57,26 @@ function RoguePropertyDefinition:GetOrCreateInstance(parent) self:GetEncodedDefaultValue()) end +function RoguePropertyDefinition:SetParentPropertyTableDefinition(parentPropertyTableDefinition) + self._parentPropertyTableDefinition = parentPropertyTableDefinition +end + function RoguePropertyDefinition:GetParentPropertyDefinition() - return self._roguePropertyTableDefinition + return self._parentPropertyTableDefinition +end + +function RoguePropertyDefinition:CanAssign(value, _strict) + if self._valueType == typeof(value) then + return true + else + return false, string.format("got %q, expected %q when assigning to %q", self._valueType, typeof(value), self:GetFullName()) + end +end + +function RoguePropertyDefinition:SetName(name) + assert(type(name) == "string", "Bad name") + + self._name = name end --[=[ @@ -67,6 +87,18 @@ function RoguePropertyDefinition:GetName(): string return self._name end +--[=[ + Gets the full name of the rogue property + @return string +]=] +function RoguePropertyDefinition:GetFullName(): string + if self._parentPropertyTableDefinition then + return self._parentPropertyTableDefinition:GetFullName() .. "." .. self._name + else + return self._name + end +end + --[=[ Gets the default value for the property @return TProperty diff --git a/src/rogue-properties/src/Shared/Definition/RoguePropertyDefinitionArrayHelper.lua b/src/rogue-properties/src/Shared/Definition/RoguePropertyDefinitionArrayHelper.lua new file mode 100644 index 0000000000..0e8974120f --- /dev/null +++ b/src/rogue-properties/src/Shared/Definition/RoguePropertyDefinitionArrayHelper.lua @@ -0,0 +1,71 @@ +--[=[ + @class RoguePropertyDefinitionArrayHelper +]=] + +local require = require(script.Parent.loader).load(script) + +local RoguePropertyArrayUtils = require("RoguePropertyArrayUtils") + +local RoguePropertyDefinitionArrayHelper = {} +RoguePropertyDefinitionArrayHelper.ClassName = "RoguePropertyDefinitionArrayHelper" +RoguePropertyDefinitionArrayHelper.__index = RoguePropertyDefinitionArrayHelper + +function RoguePropertyDefinitionArrayHelper.new(propertyTableDefinition, defaultArrayData, requiredPropertyDefinition) + local self = setmetatable({}, RoguePropertyDefinitionArrayHelper) + + self._propertyTableDefinition = assert(propertyTableDefinition, "No propertyTableDefinition") + self._defaultArrayData = assert(defaultArrayData, "No defaultArrayData") + self._requiredPropertyDefinition = assert(requiredPropertyDefinition, "No requiredPropertyDefinition") + + return self +end + +function RoguePropertyDefinitionArrayHelper:IsArray() + return self._defaultArrayData ~= nil +end + +function RoguePropertyDefinitionArrayHelper:GetDefaultArrayData() + return self._defaultArrayData +end + +function RoguePropertyDefinitionArrayHelper:GetPropertyTableDefinition() + return self._propertyTableDefinition +end + +function RoguePropertyDefinitionArrayHelper:GetDefaultDefinitions() + if self._defaultDefinitions then + return self._defaultDefinitions + end + + self._defaultDefinitions = RoguePropertyArrayUtils.createDefinitionsFromArrayData(self._defaultArrayData, self._propertyTableDefinition) + return self._defaultDefinitions +end + +function RoguePropertyDefinitionArrayHelper:GetRequiredPropertyDefinition() + return self._requiredPropertyDefinition +end + +function RoguePropertyDefinitionArrayHelper:CanAssign(arrayValue, strict) + if type(arrayValue) ~= "table" then + return false, string.format("got %q, expected %q", self._valueType, typeof(arrayValue)) + end + + for key, value in pairs(arrayValue) do + if type(key) == "number" then + local canAssign, message = self._requiredPropertyDefinition:CanAssign(value, strict) + if not canAssign then + return false, string.format("Array at %s was %q, cannot assign due to %q", tostring(key), typeof(value), tostring(message)) + end + end + end + + return true +end + +function RoguePropertyDefinitionArrayHelper:CanAssignAsArrayMember(value, strict) + assert(type(strict) == "boolean", "Bad strict") + + return self._requiredPropertyDefinition:CanAssign(value, strict) +end + +return RoguePropertyDefinitionArrayHelper \ No newline at end of file diff --git a/src/rogue-properties/src/Shared/Definition/RoguePropertyTableDefinition.lua b/src/rogue-properties/src/Shared/Definition/RoguePropertyTableDefinition.lua index 557ed4940a..a9803158f8 100644 --- a/src/rogue-properties/src/Shared/Definition/RoguePropertyTableDefinition.lua +++ b/src/rogue-properties/src/Shared/Definition/RoguePropertyTableDefinition.lua @@ -5,43 +5,139 @@ local require = require(script.Parent.loader).load(script) -local RoguePropertyTable = require("RoguePropertyTable") -local ServiceBag = require("ServiceBag") +local DuckTypeUtils = require("DuckTypeUtils") local RoguePropertyDefinition = require("RoguePropertyDefinition") +local RoguePropertyDefinitionArrayHelper = require("RoguePropertyDefinitionArrayHelper") +local RoguePropertyTable = require("RoguePropertyTable") +local RxBrioUtils = require("RxBrioUtils") local RxInstanceUtils = require("RxInstanceUtils") +local ServiceBag = require("ServiceBag") local RoguePropertyService = require("RoguePropertyService") -local DuckTypeUtils = require("DuckTypeUtils") -local RxBrioUtils = require("RxBrioUtils") +local RoguePropertyArrayUtils = require("RoguePropertyArrayUtils") +local Set = require("Set") local RoguePropertyTableDefinition = {} -- Inherits from RoguePropertyDefinition RoguePropertyTableDefinition.ClassName = "RoguePropertyTableDefinition" RoguePropertyTableDefinition.__index = RoguePropertyTableDefinition -function RoguePropertyTableDefinition.new(tableName: string, propertyDefinition: {[string]: any}, roguePropertyTableDefinition) - assert(type(tableName) == "string", "Bad tableName") - assert(type(propertyDefinition) == "table", "Bad propertyDefinition") +function RoguePropertyTableDefinition.new(tableName, defaultValueTable) + local self = setmetatable(RoguePropertyDefinition.new(), RoguePropertyTableDefinition) - local self = setmetatable(RoguePropertyDefinition.new(tableName, propertyDefinition, roguePropertyTableDefinition), RoguePropertyTableDefinition) + if tableName then + self:SetName(tableName) + end + + if defaultValueTable then + self:SetDefaultValue(defaultValueTable) + end + + return self +end + +function RoguePropertyTableDefinition.isRoguePropertyTableDefinition(value) + return DuckTypeUtils.isImplementation(RoguePropertyTableDefinition, value) +end + +function RoguePropertyTableDefinition:SetDefaultValue(defaultValueTable) + assert(type(defaultValueTable) == "table", "Bad defaultValueTable") + + RoguePropertyDefinition.SetDefaultValue(self, defaultValueTable) self._definitionMap = {} - for key, defaultValue in pairs(propertyDefinition) do - if type(defaultValue) == "table" then - if RoguePropertyDefinition.isRoguePropertyDefinition(defaultValue) then - self._definitionMap[key] = defaultValue + local defaultArrayData = {} + + for key, defaultValue in pairs(defaultValueTable) do + if type(key) == "number" then + table.insert(defaultArrayData, defaultValue) + else + if type(defaultValue) == "table" then + local tableDefinition = RoguePropertyTableDefinition.new() + tableDefinition:SetName(key) + tableDefinition:SetParentPropertyTableDefinition(self) + tableDefinition:SetDefaultValue(defaultValue) + + self._definitionMap[key] = tableDefinition else - self._definitionMap[key] = RoguePropertyTableDefinition.new(key, defaultValue, self) + local definition = RoguePropertyDefinition.new() + definition:SetName(key) + definition:SetParentPropertyTableDefinition(self) + definition:SetDefaultValue(defaultValue) + + self._definitionMap[key] = definition end + end + end + + if next(defaultArrayData) ~= nil then + -- Enforce array data types for sanity + local requiredPropertyDefinitionTemplate, message = RoguePropertyArrayUtils.createRequiredPropertyDefinitionFromArray(defaultArrayData, self) + + if requiredPropertyDefinitionTemplate then + self._arrayDefinitionHelper = RoguePropertyDefinitionArrayHelper.new(self, defaultArrayData, requiredPropertyDefinitionTemplate) else - self._definitionMap[key] = RoguePropertyDefinition.new(key, defaultValue, self) + error(string.format("[RoguePropertyTableDefinition] - Could not create infer array type definition. Error: %s", message)) end end +end - return self + +function RoguePropertyTableDefinition:CanAssign(mainValue, strict) + assert(type(strict) == "boolean", "Bad strict") + + if type(mainValue) ~= "table" then + return false, string.format("got %q, expected %q when assigning to %q", self._valueType, typeof(mainValue), self:GetFullName()) + end + + local remainingKeys + if strict then + remainingKeys = Set.fromKeys(self._definitionMap) + else + remainingKeys = {} + end + + for key, value in pairs(mainValue) do + remainingKeys[key] = nil + + if type(key) == "number" then + if self._arrayDefinitionHelper then + local canAssign, message = self._arrayDefinitionHelper:CanAssignAsArrayMember(value, strict) + if not canAssign then + if message then + return false, message + else + return false, string.format("Bad index %q of %q due to %s", tostring(key), self:GetFullName(), tostring(message)) + end + end + else + return false, string.format("Bad index %q, %q is not an array", tostring(key), self:GetFullName()) + end + else + if self._definitionMap[key] then + local canAssign, message = self._definitionMap[key]:CanAssign(value, strict) + if not canAssign then + if message then + return false, message + else + return false, string.format("Bad index %q of %q due to %s", tostring(key), self:GetFullName(), tostring(message)) + end + end + else + return false, string.format("%s.%s is not an expected member", self:GetFullName(), tostring(key)) + end + end + end + + -- We missed some keys + if next(remainingKeys) ~= nil then + return false, string.format("Had %d unassigned keys %q while assigning to %q", #remainingKeys, table.concat(remainingKeys, ", "), self:GetFullName()) + end + + return true end -function RoguePropertyTableDefinition.isRoguePropertyTableDefinition(value) - return DuckTypeUtils.isImplementation(RoguePropertyTableDefinition, value) +function RoguePropertyTableDefinition:GetDefinitionArrayHelper() + return self._arrayDefinitionHelper end function RoguePropertyTableDefinition:GetDefinitionMap() @@ -72,7 +168,14 @@ function RoguePropertyTableDefinition:Get(serviceBag, adornee) assert(ServiceBag.isServiceBag(serviceBag), "Bad serviceBag") assert(typeof(adornee) == "Instance", "Bad adornee") - return RoguePropertyTable.new(adornee, serviceBag, self) + local roguePropertyTable = RoguePropertyTable.new(adornee, serviceBag, self) + + if not self:GetParentPropertyDefinition() then + -- Set default value for top level only + roguePropertyTable:SetCanInitialize(serviceBag:GetService(RoguePropertyService):CanInitializeProperties()) + end + + return roguePropertyTable end RoguePropertyTableDefinition.GetPropertyTable = RoguePropertyTableDefinition.Get @@ -80,20 +183,20 @@ RoguePropertyTableDefinition.GetPropertyTable = RoguePropertyTableDefinition.Get --[=[ Observes the current container while it exists for the given adornee. - @param serviceBag ServiceBag @param adornee Instance + @param canInitialize boolean @return Observable> ]=] -function RoguePropertyTableDefinition:ObserveContainerBrio(serviceBag, adornee) - assert(ServiceBag.isServiceBag(serviceBag), "Bad serviceBag") +function RoguePropertyTableDefinition:ObserveContainerBrio(adornee, canInitialize) assert(typeof(adornee) == "Instance", "Bad adornee") + assert(type(canInitialize) == "boolean", "Bad canInitialize") -- TODO: Optimize so we aren't duplcating this logic each time we index a property - self:GetContainer(serviceBag, adornee) + self:GetContainer(adornee, canInitialize) local parentDefinition = self:GetParentPropertyDefinition() if parentDefinition then - return parentDefinition:ObserveContainerBrio(serviceBag, adornee) + return parentDefinition:ObserveContainerBrio(adornee, canInitialize) :Pipe({ RxBrioUtils.switchMapBrio(function(parent) return RxInstanceUtils.observeLastNamedChildBrio(parent, "Folder", self:GetName()) @@ -106,18 +209,18 @@ end --[=[ Gets the current container for the given adornee. - @param serviceBag ServiceBag @param adornee Instance + @param canInitialize boolean @return Folder? ]=] -function RoguePropertyTableDefinition:GetContainer(serviceBag, adornee) - assert(ServiceBag.isServiceBag(serviceBag), "Bad serviceBag") +function RoguePropertyTableDefinition:GetContainer(adornee, canInitialize) assert(typeof(adornee) == "Instance", "Bad adornee") + assert(type(canInitialize) == "boolean", "Bad canInitialize") local parent local parentDefinition = self:GetParentPropertyDefinition() if parentDefinition then - parent = parentDefinition:GetContainer(serviceBag, adornee) + parent = parentDefinition:GetContainer(adornee, canInitialize) else parent = adornee end @@ -126,7 +229,7 @@ function RoguePropertyTableDefinition:GetContainer(serviceBag, adornee) return nil end - if serviceBag:GetService(RoguePropertyService):CanInitializeProperties() then + if canInitialize then return self:GetOrCreateInstance(parent) else return parent:FindFirstChild(self:GetName()) @@ -150,21 +253,32 @@ end function RoguePropertyTableDefinition:__index(index) assert(type(index) == "string", "Bad index") - if index == "_definitionMap" then - return rawget(self, "_definitionMap") - elseif index == "_roguePropertyTableDefinition" then - return rawget(self, "_roguePropertyTableDefinition") + if index == "_definitionMap" or index == "_arrayDefinitionHelper" or index == "_parentPropertyTableDefinition" then + return rawget(self, index) elseif RoguePropertyTableDefinition[index] then return RoguePropertyTableDefinition[index] elseif RoguePropertyDefinition[index] then return RoguePropertyDefinition[index] - else + elseif type(index) == "string" then local definitions = rawget(self, "_definitionMap") if definitions[index] then return definitions[index] else - error(("Bad definition %q"):format(tostring(index))) + error(string.format("Bad definition %q", tostring(index))) + end + elseif type(index) == "number" then + local definitionArrayHelper = rawget(self, "_arrayDefinitionHelper") + if definitionArrayHelper then + local defaultDefinitions = definitionArrayHelper:GetDefaultDefinitions() + if defaultDefinitions then + return defaultDefinitions[index] + else + -- TODO: Maybe consider returning a generalized property here instead... + error(string.format("Bad definition %q", tostring(index))) + end + else + error(string.format("Bad definition %q - Not an array", tostring(index))) end end end diff --git a/src/rogue-properties/src/Shared/Property/RogueProperty.lua b/src/rogue-properties/src/Shared/Implementation/RogueProperty.lua similarity index 80% rename from src/rogue-properties/src/Shared/Property/RogueProperty.lua rename to src/rogue-properties/src/Shared/Implementation/RogueProperty.lua index 1b67b8eaf9..73a89ffd15 100644 --- a/src/rogue-properties/src/Shared/Property/RogueProperty.lua +++ b/src/rogue-properties/src/Shared/Implementation/RogueProperty.lua @@ -7,7 +7,6 @@ local require = require(script.Parent.loader).load(script) local RogueAdditiveProvider = require("RogueAdditiveProvider") local RogueMultiplierProvider = require("RogueMultiplierProvider") local RoguePropertyBinderGroups = require("RoguePropertyBinderGroups") -local RoguePropertyChangedSignalConnection = require("RoguePropertyChangedSignalConnection") local RoguePropertyModifierUtils = require("RoguePropertyModifierUtils") local RoguePropertyService = require("RoguePropertyService") local RoguePropertyUtils = require("RoguePropertyUtils") @@ -17,7 +16,7 @@ local RxBinderUtils = require("RxBinderUtils") local RxBrioUtils = require("RxBrioUtils") local RxInstanceUtils = require("RxInstanceUtils") local RxValueBaseUtils = require("RxValueBaseUtils") -local ValueObject = require("ValueObject") +local RxSignal = require("RxSignal") local RogueProperty = {} RogueProperty.ClassName = "RogueProperty" @@ -32,14 +31,31 @@ function RogueProperty.new(adornee, serviceBag, definition) self._adornee = assert(adornee, "Bad adornee") self._definition = assert(definition, "Bad definition") + self._canInitialize = false setmetatable(self, RogueProperty) - if self._roguePropertyService:CanInitializeProperties() then - self:GetBaseValueObject() + return self +end + +function RogueProperty:SetCanInitialize(canInitialize) + assert(type(canInitialize) == "boolean", "Bad canInitialize") + + if rawget(self, "_canInitialize") ~= canInitialize then + rawset(self, "_canInitialize", canInitialize) + + if canInitialize then + self:GetBaseValueObject() + end end +end - return self +function RogueProperty:GetAdornee() + return self._adornee +end + +function RogueProperty:CanInitialize() + return rawget(self, "_canInitialize") end function RogueProperty:GetBaseValueObject() @@ -47,7 +63,7 @@ function RogueProperty:GetBaseValueObject() local parentDefinition = self._definition:GetParentPropertyDefinition() if parentDefinition then - parent = parentDefinition:GetContainer(self._serviceBag, self._adornee) + parent = parentDefinition:GetContainer(self._adornee, self:CanInitialize()) else parent = self._adornee end @@ -56,7 +72,7 @@ function RogueProperty:GetBaseValueObject() return nil end - if self._roguePropertyService:CanInitializeProperties() then + if self:CanInitialize() then return self._definition:GetOrCreateInstance(parent) else return parent:FindFirstChild(self._definition:GetName()) @@ -66,7 +82,7 @@ end function RogueProperty:_observeBaseValueBrio() local parentDefinition = self._definition:GetParentPropertyDefinition() if parentDefinition then - return parentDefinition:ObserveContainerBrio(self._serviceBag, self._adornee) + return parentDefinition:ObserveContainerBrio(self._adornee, self:CanInitialize()) :Pipe({ RxBrioUtils.switchMapBrio(function(container) return RxInstanceUtils.observeLastNamedChildBrio( @@ -81,18 +97,22 @@ function RogueProperty:_observeBaseValueBrio() end function RogueProperty:SetBaseValue(value) + assert(self._definition:CanAssign(value, false)) -- This has a good error message + local baseValue = self:GetBaseValueObject() if baseValue then baseValue.Value = self:_encodeValue(value) else - warn("[RogueProperty] - Failed to get the baseValue to parent") + warn(string.format("[RogueProperty.SetBaseValue] - Failed to get the baseValue for %q on %q", self._definition:GetFullName(), self._adornee:GetFullName())) end end function RogueProperty:SetValue(value) + assert(self._definition:CanAssign(value, false)) -- This has a good error message + local baseValue = self:GetBaseValueObject() if not baseValue then - warn("[RogueProperty] - Failed to get the baseValue to parent") + warn(string.format("[RogueProperty.SetValue] - Failed to get the baseValue for %q on %q", self._definition:GetFullName(), self._adornee:GetFullName())) return end @@ -137,7 +157,6 @@ function RogueProperty:ObserveModifiersBrio() return self:_observeBaseValueBrio() :Pipe({ RxBrioUtils.flatMapBrio(function(baseValue) - print("Got base value") if baseValue then return RxBinderUtils.observeBoundChildClassesBrio(self._roguePropertyBinderGroups.RogueModifiers:GetBinders(), baseValue) else @@ -186,6 +205,7 @@ function RogueProperty:Observe() end); RxBrioUtils.emitOnDeath(self._definition:GetDefaultValue()); Rx.defaultsTo(self._definition:GetDefaultValue()); + Rx.distinct(); }) end @@ -196,7 +216,7 @@ function RogueProperty:CreateMultiplier(amount, source) local baseValue = self:GetBaseValueObject() if not baseValue then - warn("[RogueProperty] - Failed to get the baseValue to parent") + warn(string.format("[RogueProperty.CreateMultiplier] - Failed to get the baseValue for %q on %q", self._definition:GetFullName(), self._adornee:GetFullName())) end local multiplier = provider:Create(amount, source) @@ -212,7 +232,7 @@ function RogueProperty:CreateAdditive(amount, source) local baseValue = self:GetBaseValueObject() if not baseValue then - warn("[RogueProperty] - Failed to get the baseValue to parent") + warn(string.format("[RogueProperty.CreateAdditive] - Failed to get the baseValue for %q on %q", self._definition:GetFullName(), self._adornee:GetFullName())) end local multiplier = provider:Create(amount, source) @@ -226,7 +246,7 @@ function RogueProperty:CreateSetter(value, source) local baseValue = self:GetBaseValueObject() if not baseValue then - warn("[RogueProperty] - Failed to get the baseValue to parent") + warn(string.format("[RogueProperty.CreateSetter] - Failed to get the baseValue for %q on %q", self._definition:GetFullName(), self._adornee:GetFullName())) end local setter = provider:Create(value, source) @@ -272,22 +292,7 @@ function RogueProperty:_encodeValue(current) end function RogueProperty:GetChangedEvent() - return { - Connect = function(_, callback) - assert(type(callback) == "function", "Bad callback") - return RoguePropertyChangedSignalConnection.new(function(connMaid) - local valueObject = ValueObject.new(self._definition:GetDefaultValue()) - connMaid:GiveTask(valueObject) - - connMaid:GiveTask(self:Observe():Subscribe(function(value) - valueObject.Value = value - end)) - - -- After observing, so we can emit only changes. - connMaid:GiveTask(valueObject.Changed:Connect(callback)) - end) - end; - } + return RxSignal.new(self:Observe()) end diff --git a/src/rogue-properties/src/Shared/Implementation/RoguePropertyArrayHelper.lua b/src/rogue-properties/src/Shared/Implementation/RoguePropertyArrayHelper.lua new file mode 100644 index 0000000000..c49519c1c8 --- /dev/null +++ b/src/rogue-properties/src/Shared/Implementation/RoguePropertyArrayHelper.lua @@ -0,0 +1,188 @@ +--[=[ + @class RoguePropertyArrayHelper +]=] + +local require = require(script.Parent.loader).load(script) + +local BaseObject = require("BaseObject") +local RoguePropertyArrayUtils = require("RoguePropertyArrayUtils") + +local RoguePropertyArrayHelper = setmetatable({}, BaseObject) +RoguePropertyArrayHelper.ClassName = "RoguePropertyArrayHelper" +RoguePropertyArrayHelper.__index = RoguePropertyArrayHelper + +function RoguePropertyArrayHelper.new(serviceBag, arrayDefinitionHelper, roguePropertyTable) + local self = setmetatable(BaseObject.new(), RoguePropertyArrayHelper) + + self._serviceBag = assert(serviceBag, "No serviceBag") + self._roguePropertyTable = assert(roguePropertyTable, "No roguePropertyTable") + self._arrayDefinitionHelper = assert(arrayDefinitionHelper, "No arrayDefinitionHelper") + + return self +end + +function RoguePropertyArrayHelper:SetCanInitialize(canInitialize) + if canInitialize then + self:GetArrayRogueProperties() + end +end + +function RoguePropertyArrayHelper:GetArrayRogueProperty(index) + assert(type(index) == "number", "Bad index") + + -- TODO: Maybe return something general for that index + -- TODO: This is slow.... + local rogueProperties = self:GetArrayRogueProperties() + + return rogueProperties[index] +end + +function RoguePropertyArrayHelper:GetArrayRogueProperties() + -- Dynamic construction of the properties based upon when exists + local container = self._roguePropertyTable:GetContainer() + + -- Force initialization + if self._roguePropertyTable:CanInitialize() then + if not (container and container:GetAttribute("HasInitializedArrayComponent")) then + container:SetAttribute("HasInitializedArrayComponent", true) + local properties = self:_getDefaultRogueProperties() + for _, rogueProperty in pairs(properties) do + -- Force initialization once and only once... + rogueProperty:SetCanInitialize(true) + rogueProperty:SetCanInitialize(false) + end + end + end + + if not container then + return self:_getDefaultRogueProperties() + end + + local adornee = self._roguePropertyTable:GetAdornee() + + + local definitions = RoguePropertyArrayUtils.createDefinitionsFromContainer(container, self._arrayDefinitionHelper:GetPropertyTableDefinition()) + local rogueProperties = {} + + for index, definition in pairs(definitions) do + local property = definition:Get(self._serviceBag, adornee) + property:SetCanInitialize(false) -- Explicitly not going to reinitialize + rogueProperties[index] = property + end + + return rogueProperties +end + +function RoguePropertyArrayHelper:_getDefaultRogueProperties() + if self._defaultRogueProperties then + return self._defaultRogueProperties + end + + local defaultRogueProperties = {} + local adornee = self._roguePropertyTable:GetAdornee() + for _, definition in pairs(self._arrayDefinitionHelper:GetDefaultDefinitions()) do + local property = definition:Get(self._serviceBag, adornee) + table.insert(defaultRogueProperties, property) + end + + self._defaultRogueProperties = defaultRogueProperties + return self._defaultRogueProperties +end + +function RoguePropertyArrayHelper:SetArrayBaseData(arrayData) + assert(self._arrayDefinitionHelper:CanAssign(arrayData, false)) -- This has good error messages + + local container = self._roguePropertyTable:GetContainer() + if not container then + warn("[RoguePropertyArrayHelper.SetArrayBaseData] - Failed to get container") + return + end + + -- Add all + local available = self:GetArrayRogueProperties() + local parentPropertyTableDefinition = self._arrayDefinitionHelper:GetPropertyTableDefinition() + local adornee = self._roguePropertyTable:GetAdornee() + local definitions = RoguePropertyArrayUtils.createDefinitionsFromArrayData(arrayData, parentPropertyTableDefinition) + + for index, definition in pairs(definitions) do + if available[index] and available[index]:GetDefinition():GetValueType() == definition:GetValueType() then + available[index]:SetBaseValue(definition:GetDefaultValue()) + else + -- Cleanup this old one and setup a new one + if available[index] then + available[index]:GetBaseValueObject():Destroy() + end + + local property = definition:Get(self._serviceBag, adornee) + + if self._roguePropertyTable:CanInitialize() then + property:SetCanInitialize(true) -- Initialize once + property:SetCanInitialize(false) + end + end + end + + self:_removeUnspecified(container, definitions) +end + +function RoguePropertyArrayHelper:SetArrayData(arrayData) + assert(self._arrayDefinitionHelper:CanAssign(arrayData, false)) -- This has good error messages + + local container = self._roguePropertyTable:GetContainer() + if not container then + warn("[RoguePropertyArrayHelper.SetArrayData] - Failed to get container") + return + end + + local available = self:GetArrayRogueProperties() + local parentPropertyTableDefinition = self._arrayDefinitionHelper:GetPropertyTableDefinition() + local adornee = self._roguePropertyTable:GetAdornee() + local definitions = RoguePropertyArrayUtils.createDefinitionsFromArrayData(arrayData, parentPropertyTableDefinition) + + for index, definition in pairs(definitions) do + if available[index] and available[index]:GetDefinition():GetValueType() == definition:GetValueType() then + available[index]:SetValue(definition:GetDefaultValue()) + else + -- Cleanup this old one and setup a new one + if available[index] then + available[index]:GetBaseValueObject():Destroy() + end + + local property = definition:Get(self._serviceBag, adornee) + property:SetCanInitialize(true) -- Initialize once + property:SetCanInitialize(false) + end + end + + self:_removeUnspecified(container, definitions) +end + +function RoguePropertyArrayHelper:_removeUnspecified(container, definitions) + for _, item in pairs(container:GetChildren()) do + local index = RoguePropertyArrayUtils.getIndexFromName(item.Name) + if index then + if not definitions[index] then + item:Destroy() + end + end + end +end + + +function RoguePropertyArrayHelper:GetArrayBaseValues() + local result = {} + for index, rogueProperty in pairs(self:GetArrayRogueProperties()) do + result[index] = rogueProperty:GetBaseValue() + end + return result +end + +function RoguePropertyArrayHelper:GetArrayValues() + local result = {} + for index, rogueProperty in pairs(self:GetArrayRogueProperties()) do + result[index] = rogueProperty:GetValue() + end + return result +end + +return RoguePropertyArrayHelper \ No newline at end of file diff --git a/src/rogue-properties/src/Shared/Implementation/RoguePropertyTable.lua b/src/rogue-properties/src/Shared/Implementation/RoguePropertyTable.lua new file mode 100644 index 0000000000..5a71e6570e --- /dev/null +++ b/src/rogue-properties/src/Shared/Implementation/RoguePropertyTable.lua @@ -0,0 +1,250 @@ +--[=[ + @class RoguePropertyTable +]=] + +local require = require(script.Parent.loader).load(script) + +local RogueProperty = require("RogueProperty") +local Rx = require("Rx") +local RoguePropertyArrayHelper = require("RoguePropertyArrayHelper") + +local RoguePropertyTable = {} -- inherits from RogueProperty +RoguePropertyTable.ClassName = "RoguePropertyTable" +RoguePropertyTable.__index = RoguePropertyTable + +function RoguePropertyTable.new(adornee, serviceBag, roguePropertyTableDefinition) + local self = setmetatable(RogueProperty.new(adornee, serviceBag, roguePropertyTableDefinition), RoguePropertyTable) + + rawset(self, "_properties", {}) + + local arrayDefinitionHelper = self:GetDefinition():GetDefinitionArrayHelper() + if arrayDefinitionHelper then + rawset(self, "_arrayHelper", RoguePropertyArrayHelper.new(serviceBag, arrayDefinitionHelper, self)) + end + + return self +end + +function RoguePropertyTable:SetCanInitialize(canInitialize) + assert(type(canInitialize) == "boolean", "Bad canInitialize") + + RogueProperty.SetCanInitialize(self, canInitialize) + + for _, property in pairs(self:GetRogueProperties()) do + property:SetCanInitialize(canInitialize) + end + + local arrayHelper = rawget(self, "_arrayHelper") + if arrayHelper then + arrayHelper:SetCanInitialize(canInitialize) + end +end + +function RoguePropertyTable:ObserveContainerBrio() + return self._definition:ObserveContainerBrio(self._adornee, self:CanInitialize()) +end + +function RoguePropertyTable:GetContainer() + return self._definition:GetContainer(self._adornee, self:CanInitialize()) +end + +function RoguePropertyTable:SetBaseValue(newBaseValue) + assert(self._definition:CanAssign(newBaseValue, false)) -- This has a good error message + + local arrayData = {} + + for propertyName, value in pairs(newBaseValue) do + if type(propertyName) == "string" then + local rogueProperty = self:GetRogueProperty(propertyName) + if not rogueProperty then + error(string.format("Bad property %q", tostring(propertyName))) + end + + rogueProperty:SetBaseValue(value) + else + table.insert(arrayData, value) + end + end + + if next(arrayData) ~= nil then + local arrayHelper = rawget(self, "_arrayHelper") + if arrayHelper then + arrayHelper:SetArrayBaseData(arrayData) + else + error("Had array data but we are not an array") + end + end +end + +function RoguePropertyTable:SetValue(newValue) + assert(self._definition:CanAssign(newValue, false)) -- This has a good error message + + local arrayData = {} + + for propertyName, value in pairs(newValue) do + if type(propertyName) == "string" then + local rogueProperty = self:GetRogueProperty(propertyName) + if not rogueProperty then + error(string.format("Bad property %q", tostring(propertyName))) + end + + rogueProperty:SetValue(value) + else + table.insert(arrayData, value) + end + end + + if next(arrayData) ~= nil then + local arrayHelper = rawget(self, "_arrayHelper") + if arrayHelper then + arrayHelper:SetArrayData(arrayData) + else + error("Had array data but we are not an array") + end + end +end + +function RoguePropertyTable:GetRogueProperties() + local arrayHelper = rawget(self, "_arrayHelper") + local properties = arrayHelper and arrayHelper:GetArrayRogueProperties() or {} + + for propertyName, rogueDefinition in pairs(self._definition:GetDefinitionMap()) do + local rogueProperty = self:GetRogueProperty(rogueDefinition:GetName()) + if not rogueProperty then + error(string.format("Bad property %q", tostring(rogueDefinition:GetName()))) + end + + properties[propertyName] = rogueProperty + end + + return properties +end + +function RoguePropertyTable:GetValue() + local arrayHelper = rawget(self, "_arrayHelper") + local values = arrayHelper and arrayHelper:GetArrayValues() or {} + + for key, rogueDefinition in pairs(self._definition:GetDefinitionMap()) do + local property = self:GetRogueProperty(rogueDefinition:GetName()) + assert(property, "Failed to get rogue property") + + values[key] = property:GetValue() + end + + return values +end + +function RoguePropertyTable:GetBaseValue() + local arrayHelper = rawget(self, "_arrayHelper") + local values = arrayHelper and arrayHelper:GetArrayBaseValues() or {} + + for key, rogueDefinition in pairs(self._definition:GetDefinitionMap()) do + local property = self:GetRogueProperty(rogueDefinition:GetName()) + assert(property, "Failed to get rogue property") + + values[key] = property:GetBaseValue() + end + + return values +end + +function RoguePropertyTable:Observe() + local arrayHelper = rawget(self, "_arrayHelper") + if arrayHelper then + warn("[RoguePropertyTable] - Observing arrays not supported yet! Changed event will only fire with dictionary components.") + + return self:_observeDictionary() + end + + return self:_observeDictionary() +end + +function RoguePropertyTable:_observeDictionary() + -- ok, this is definitely slow + + local toObserve = {} + + for propertyName, rogueDefinition in pairs(self._definition:GetDefinitionMap()) do + local rogueProperty = self:GetRogueProperty(rogueDefinition:GetName()) + if not rogueProperty then + error(string.format("Bad property %q", tostring(rogueDefinition:GetName()))) + end + + toObserve[propertyName] = rogueProperty:Observe() + end + + if next(toObserve) == nil then + return Rx.of({}) + end + + return Rx.combineLatest(toObserve) +end + +function RoguePropertyTable:GetRogueProperty(name) + assert(type(name) == "string", "Bad name") + + -- Caching these things doesn't do a whole lot, but saves on table allocation. + if self._properties[name] then + return self._properties[name] + end + + local definition = self._definition:GetDefinition(name) + if definition then + local newProperty = definition:Get(self._serviceBag, self._adornee) + newProperty:SetCanInitialize(self:CanInitialize()) + + self._properties[name] = newProperty + return newProperty + else + return nil + end +end + +function RoguePropertyTable:__newindex(index, value) + if index == "Value" then + self:SetValue(value) + elseif index == "Changed" then + error("Cannot set .Changed event") + elseif RoguePropertyTable[index] then + error(string.format("Cannot set %q", tostring(index))) + else + error(string.format("Bad index %q", tostring(index))) + end +end + +function RoguePropertyTable:__index(index) + assert(type(index) == "string", "Bad index") + + if RoguePropertyTable[index] then + return RoguePropertyTable[index] + elseif rawget(RogueProperty, index) ~= nil then + return rawget(RogueProperty, index) + elseif index == "Value" then + return self:GetValue() + elseif index == "Changed" then + return self:GetChangedEvent() + elseif type(index) == "string" then + local property = self:GetRogueProperty(index) + if not property then + error(string.format("Bad index %q", tostring(index))) + end + return property + elseif type(index) == "number" then + local arrayHelper = rawget(self, "_arrayHelper") + if arrayHelper then + local result = arrayHelper:GetArrayRogueProperty(index) + + if result then + return result + else + error(string.format("Bad index %q", tostring(index))) + end + else + error(string.format("Bad index %q - We are not an array", tostring(index))) + end + else + error(string.format("Bad index %q", tostring(index))) + end +end + +return RoguePropertyTable \ No newline at end of file diff --git a/src/rogue-properties/src/Shared/Property/RoguePropertyUtils.lua b/src/rogue-properties/src/Shared/Implementation/RoguePropertyUtils.lua similarity index 100% rename from src/rogue-properties/src/Shared/Property/RoguePropertyUtils.lua rename to src/rogue-properties/src/Shared/Implementation/RoguePropertyUtils.lua diff --git a/src/rogue-properties/src/Shared/Property/RoguePropertyChangedSignalConnection.lua b/src/rogue-properties/src/Shared/Property/RoguePropertyChangedSignalConnection.lua deleted file mode 100644 index d6dd9e34a2..0000000000 --- a/src/rogue-properties/src/Shared/Property/RoguePropertyChangedSignalConnection.lua +++ /dev/null @@ -1,44 +0,0 @@ ---[=[ - @class RoguePropertyChangedSignalConnection -]=] - -local require = require(script.Parent.loader).load(script) - -local Maid = require("Maid") - -local RoguePropertyChangedSignalConnection = {} -RoguePropertyChangedSignalConnection.ClassName = "RoguePropertyChangedSignalConnection" -RoguePropertyChangedSignalConnection.__index = RoguePropertyChangedSignalConnection - -function RoguePropertyChangedSignalConnection.new(connect) - local self = setmetatable({}, RoguePropertyChangedSignalConnection) - - self._maid = Maid.new() - - self._connected = true - connect(self._maid) - - return self -end - -function RoguePropertyChangedSignalConnection:Disconnect() - self:Destroy() -end - -function RoguePropertyChangedSignalConnection:__index(index) - if index == "IsConnected" then - return self._connected - elseif RoguePropertyChangedSignalConnection[index] then - return RoguePropertyChangedSignalConnection[index] - else - error(("Bad index %q for RoguePropertyChangedSignalConnection"):format(tostring(index))) - end -end - -function RoguePropertyChangedSignalConnection:Destroy() - self._connected = false - self._maid:DoCleaning() - -- Avoid setting the metatable so calling methods is always valid -end - -return RoguePropertyChangedSignalConnection \ No newline at end of file diff --git a/src/rogue-properties/src/Shared/Table/RoguePropertyTable.lua b/src/rogue-properties/src/Shared/Table/RoguePropertyTable.lua deleted file mode 100644 index e1674833a2..0000000000 --- a/src/rogue-properties/src/Shared/Table/RoguePropertyTable.lua +++ /dev/null @@ -1,161 +0,0 @@ ---[=[ - @class RoguePropertyTable -]=] - -local require = require(script.Parent.loader).load(script) - -local RunService = game:GetService("RunService") - -local RogueProperty = require("RogueProperty") -local Rx = require("Rx") - -local RoguePropertyTable = {} -- inherits from RogueProperty -RoguePropertyTable.ClassName = "RoguePropertyTable" -RoguePropertyTable.__index = RoguePropertyTable - -function RoguePropertyTable.new(adornee, serviceBag, roguePropertyTableDefinition) - local self = setmetatable(RogueProperty.new(adornee, serviceBag, roguePropertyTableDefinition), RoguePropertyTable) - - rawset(self, "_properties", {}) - - if RunService:IsServer() then - self:_setup() - end - - return self -end - -function RoguePropertyTable:ObserveContainerBrio() - return self._definition:ObserveContainerBrio(self._serviceBag, self._adornee) -end - -function RoguePropertyTable:GetContainer() - return self._definition:GetContainer(self._serviceBag, self._adornee) -end - -function RoguePropertyTable:SetBaseValue(newBaseValue) - assert(type(newBaseValue) == "table", "Bad newBaseValue") - - for propertyName, value in pairs(newBaseValue) do - local rogueProperty = self:GetRogueProperty(propertyName) - if not rogueProperty then - error(("Bad property %q"):format(tostring(propertyName))) - end - - rogueProperty:SetBaseValue(value) - end -end - -function RoguePropertyTable:SetValue(newBaseValue) - assert(type(newBaseValue) == "table", "Bad newBaseValue") - - for propertyName, value in pairs(newBaseValue) do - local rogueProperty = self:GetRogueProperty(propertyName) - if not rogueProperty then - error(("Bad property %q"):format(tostring(propertyName))) - end - - rogueProperty:SetValue(value) - end -end - -function RoguePropertyTable:GetValue() - local values = {} - for key, rogueDefinition in pairs(self._definition:GetDefinitionMap()) do - local property = self:GetRogueProperty(rogueDefinition:GetName()) - assert(property, "Failed to get rogue property") - - values[key] = property:GetValue() - end - - return values -end - -function RoguePropertyTable:GetBaseValue() - local values = {} - for key, rogueDefinition in pairs(self._definition:GetDefinitionMap()) do - local property = self:GetRogueProperty(rogueDefinition:GetName()) - assert(property, "Failed to get rogue property") - - values[key] = property:GetBaseValue() - end - - return values -end - -function RoguePropertyTable:Observe() - -- ok, this is definitely slow - local toObserve = {} - - for propertyName, rogueDefinition in pairs(self._definition:GetDefinitionMap()) do - local rogueProperty = self:GetRogueProperty(rogueDefinition:GetName()) - if not rogueProperty then - error(("Bad property %q"):format(tostring(rogueDefinition:GetName()))) - end - - toObserve[propertyName] = rogueProperty:Observe():Pipe({ - Rx.distinct(); - }) - end - - return Rx.combineLatest(toObserve):Pipe({ - Rx.throttleDefer(); - }) -end - -function RoguePropertyTable:_setup() - for definitionName, _ in pairs(self._definition:GetDefinitionMap()) do - self:GetRogueProperty(definitionName) - end -end - -function RoguePropertyTable:GetRogueProperty(name) - -- Caching these things doesn't do a whole lot, but saves on table allocation. - if self._properties[name] then - return self._properties[name] - end - - local definition = self._definition:GetDefinition(name) - if definition then - self._properties[name] = definition:Get(self._serviceBag, self._adornee) - return self._properties[name] - else - return nil - end -end - -function RoguePropertyTable:__newindex(index, value) - if index == "Value" then - self:SetValue(value) - elseif index == "Changed" then - error("Cannot set .Changed event") - elseif RoguePropertyTable[index] then - error(string.format("Cannot set %q", tostring(index))) - else - error(string.format("Bad index %q", tostring(index))) - end -end - -function RoguePropertyTable:__index(index) - assert(type(index) == "string", "Bad index") - - if RoguePropertyTable[index] then - return RoguePropertyTable[index] - elseif rawget(RogueProperty, index) ~= nil then - return rawget(RogueProperty, index) - elseif index == "Value" then - return self:GetValue() - elseif index == "Changed" then - return self:GetChangedEvent() - elseif type(index) == "string" then - local property = self:GetRogueProperty(index) - if not property then - error(("Bad index %q"):format(tostring(index))) - end - return property - else - error(("Bad index %q"):format(tostring(index))) - end -end - -return RoguePropertyTable \ No newline at end of file diff --git a/src/rogue-properties/test/scripts/Server/ServerMain.server.lua b/src/rogue-properties/test/scripts/Server/ServerMain.server.lua index ad180cc71c..421318f22f 100644 --- a/src/rogue-properties/test/scripts/Server/ServerMain.server.lua +++ b/src/rogue-properties/test/scripts/Server/ServerMain.server.lua @@ -15,36 +15,63 @@ serviceBag:Start() local RoguePropertyTableDefinition = require(packages.RoguePropertyTableDefinition) -local properties = RoguePropertyTableDefinition.new("CombatStats", { +local propertyDefinition = RoguePropertyTableDefinition.new("CombatStats", { Health = 100; Ultimate = { AttackDamage = 30; AbilityPower = 30; + + Sequence = { + { + Name = "Ultimate 1"; + AnimationId = "rbxassetid://1"; + }; + { + Name = "Ultimate 2"; + AnimationId = "rbxassetid://2"; + }; + }; }; HeavyPunch = { AttackDamage = 45; AbilityPower = 100; + + Sequence = { + { + Name = "HeavyPunch 1"; + AnimationId = "rbxassetid://1"; + }; + { + Name = "HeavyPunch 2"; + AnimationId = "rbxassetid://2"; + }; + }; }; + + ReticleHairRotationsDegree = { 0, 120, 240 }; }) -local propertyTable = properties:GetPropertyTable(serviceBag, workspace) -local ultAttackDamage = properties.Ultimate.AttackDamage:Get(serviceBag, workspace) -local ultAbilityPower = properties.Ultimate.AbilityPower:Get(serviceBag, workspace) +local properties = propertyDefinition:GetPropertyTable(serviceBag, workspace) +local ultAttackDamage = propertyDefinition.Ultimate.AttackDamage:Get(serviceBag, workspace) +-- local ultAbilityPower = propertyDefinition.Ultimate.AbilityPower:Get(serviceBag, workspace) -ultAttackDamage:Observe():Subscribe(function(value) - print("Attack damage", value) -end) -ultAbilityPower:Observe():Subscribe(function(value) - print("Ability power", value) -end) +-- ultAttackDamage:Observe():Subscribe(function(value) +-- print("--> Attack damage", value) +-- end) +-- ultAbilityPower:Observe():Subscribe(function(value) +-- print("--> Ability power", value) +-- end) -propertyTable.Changed:Connect(function() - print("WE CHANGED", propertyTable.Value) +print("sequence", properties.Ultimate.Sequence.Value) +print("ReticleHairRotationsDegree", properties.ReticleHairRotationsDegree.Value) + +properties.Changed:Connect(function() + print("WE CHANGED", properties.Value) end) -propertyTable:SetBaseValue({ +properties:SetBaseValue({ Health = 5; Ultimate = { @@ -56,13 +83,35 @@ propertyTable:SetBaseValue({ AttackDamage = 5; AbilityPower = 9; }; + + ReticleHairRotationsDegree = { 1, 25, 135, 325, 500 }; }) -propertyTable.Value = { +-- print("ReticleHairRotationsDegree", properties.ReticleHairRotationsDegree.Value) + +properties.ReticleHairRotationsDegree.Value = { 2, 5} + +-- print("ReticleHairRotationsDegree", properties.ReticleHairRotationsDegree.Value) + +properties.Health.Value = 25 + +-- print("properties.Ultimate.Sequence", properties.Ultimate.Sequence.Value) + +properties.Ultimate.Sequence.Value = { + { + Name = "Another value 3"; + AnimationId = "rbxassetid://3"; + }; +} + +-- print("properties.Ultimate.Sequence", properties.Ultimate.Sequence.Value) + +properties.Value = { Health = 25000; }; --- local multiplier = ultAttackDamage:CreateMultiplier(2, workspace) + +local multiplier = ultAttackDamage:CreateMultiplier(2, workspace) -- ultAttackDamage:CreateAdditive(100, workspace) -- print(ultAttackDamage:GetValue()) @@ -71,4 +120,4 @@ propertyTable.Value = { -- print(value:GetValue()) -- end) --- multiplier:Destroy() \ No newline at end of file +multiplier:Destroy() \ No newline at end of file diff --git a/src/rx/src/Shared/Observable.lua b/src/rx/src/Shared/Observable.lua index 1ac6e717c9..913c0adb4f 100644 --- a/src/rx/src/Shared/Observable.lua +++ b/src/rx/src/Shared/Observable.lua @@ -138,7 +138,7 @@ function Observable:Pipe(transformers) for _, transformer in pairs(transformers) do assert(type(transformer) == "function", "Bad transformer") current = transformer(current) - assert(Observable.isObservable(current)) + assert(Observable.isObservable(current), "Transformer must return an observable") end return current diff --git a/src/rx/src/Shared/ObservableSubscriptionTable.lua b/src/rx/src/Shared/ObservableSubscriptionTable.lua index 52442343e3..0b6be5953e 100644 --- a/src/rx/src/Shared/ObservableSubscriptionTable.lua +++ b/src/rx/src/Shared/ObservableSubscriptionTable.lua @@ -56,9 +56,10 @@ end --[=[ Observes for the key @param key TKey + @param retrieveInitialValue callback -- Optional @return Observable ]=] -function ObservableSubscriptionTable:Observe(key) +function ObservableSubscriptionTable:Observe(key, retrieveInitialValue) assert(key ~= nil, "Bad key") return Observable.new(function(sub) @@ -68,6 +69,10 @@ function ObservableSubscriptionTable:Observe(key) table.insert(self._subMap[key], sub) end + if retrieveInitialValue then + retrieveInitialValue(sub) + end + return function() if not self._subMap[key] then return diff --git a/src/rx/src/Shared/Rx.lua b/src/rx/src/Shared/Rx.lua index 7ca4bd3c42..4459f9e9e1 100644 --- a/src/rx/src/Shared/Rx.lua +++ b/src/rx/src/Shared/Rx.lua @@ -16,7 +16,6 @@ local Maid = require("Maid") local Observable = require("Observable") local Promise = require("Promise") local Symbol = require("Symbol") -local Table = require("Table") local ThrottledFunction = require("ThrottledFunction") local cancellableDelay = require("cancellableDelay") local CancelToken = require("CancelToken") @@ -1421,7 +1420,7 @@ function Rx.combineLatest(observables) end end - sub:Fire(Table.copy(latest)) + sub:Fire(table.clone(latest)) end for key, observer in pairs(observables) do @@ -1552,7 +1551,7 @@ function Rx.skip(toSkip) local maid = Maid.new() maid:GiveTask(source:Subscribe(function(...) - if skipped <= toSkip then + if skipped < toSkip then skipped = skipped + 1 return end @@ -1625,6 +1624,22 @@ function Rx.delay(seconds) end end +--[=[ + Creates an observable that will emit N seconds later. + + @param seconds number + @return Observable<()> +]=] +function Rx.delayed(seconds) + assert(type(seconds) == "number", "Bad seconds") + + return Observable.new(function(sub) + return task.delay(seconds, function() + sub:Fire() + end) + end) +end + --[=[ Emits output every `n` seconds diff --git a/src/rxbinderutils/src/Shared/RxBinderUtils.lua b/src/rxbinderutils/src/Shared/RxBinderUtils.lua index 41d90db91a..44240abd89 100644 --- a/src/rxbinderutils/src/Shared/RxBinderUtils.lua +++ b/src/rxbinderutils/src/Shared/RxBinderUtils.lua @@ -40,7 +40,7 @@ function RxBinderUtils.observeLinkedBoundClassBrio(linkName, parent, binder) return RxLinkUtils.observeValidLinksBrio(linkName, parent) :Pipe({ RxBrioUtils.flatMapBrio(function(_, linkValue) - return RxBinderUtils.observeBoundClassBrio(binder, linkValue) + return binder:ObserveBrio(linkValue) end); }); end @@ -52,18 +52,31 @@ end @param instance Instance @return Observable> ]=] -function RxBinderUtils.observeBoundChildClassBrio(binder, instance) +function RxBinderUtils.observeChildrenBrio(binder, instance) assert(Binder.isBinder(binder), "Bad binder") assert(typeof(instance) == "Instance", "Bad instance") return RxInstanceUtils.observeChildrenBrio(instance) :Pipe({ RxBrioUtils.flatMapBrio(function(child) - return RxBinderUtils.observeBoundClassBrio(binder, child) + return binder:ObserveBrio(child) end); }) end + +--[=[ + Observes bound children classes. + + @function observeBoundChildClassBrio + @param binder Binder + @param instance Instance + @return Observable> + @within RxBinderUtils +]=] +RxBinderUtils.observeBoundChildClassBrio = RxBinderUtils.observeChildrenBrio + + --[=[ Observes ainstance's parent class that is bound. diff --git a/src/rxsignal/src/Shared/RxSignal.lua b/src/rxsignal/src/Shared/RxSignal.lua index 4ed3f6a8e3..2cbbf9e968 100644 --- a/src/rxsignal/src/Shared/RxSignal.lua +++ b/src/rxsignal/src/Shared/RxSignal.lua @@ -22,7 +22,7 @@ function RxSignal.new(observable) local self = setmetatable({}, RxSignal) self._observable = observable:Pipe({ - Rx.onlyAfterDefer(); + Rx.skip(1); }) return self diff --git a/src/soundplayer/README.md b/src/soundplayer/README.md new file mode 100644 index 0000000000..1c605b1633 --- /dev/null +++ b/src/soundplayer/README.md @@ -0,0 +1,23 @@ +## SoundPlayer + + + +Sound playback helper + + + +## Installation + +``` +npm install @quenty/soundplayer --save +``` diff --git a/src/soundplayer/default.project.json b/src/soundplayer/default.project.json new file mode 100644 index 0000000000..a4f74123de --- /dev/null +++ b/src/soundplayer/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "soundplayer", + "tree": { + "$path": "src" + } +} diff --git a/src/soundplayer/package.json b/src/soundplayer/package.json new file mode 100644 index 0000000000..867391dec7 --- /dev/null +++ b/src/soundplayer/package.json @@ -0,0 +1,51 @@ +{ + "name": "@quenty/soundplayer", + "version": "1.0.0", + "description": "Sound playback helper", + "keywords": [ + "Roblox", + "Nevermore", + "Lua", + "soundplayer" + ], + "bugs": { + "url": "https://github.com/Quenty/NevermoreEngine/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Quenty/NevermoreEngine.git", + "directory": "src/soundplayer/" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/quenty" + }, + "license": "MIT", + "contributors": [ + "Quenty" + ], + "dependencies": { + "@quenty/adorneeutils": "file:../adorneeutils", + "@quenty/baseobject": "file:../baseobject", + "@quenty/blend": "file:../blend", + "@quenty/brio": "file:../brio", + "@quenty/instanceutils": "file:../instanceutils", + "@quenty/loader": "file:../loader", + "@quenty/maid": "file:../maid", + "@quenty/numberrangeutils": "file:../numberrangeutils", + "@quenty/promise": "file:../promise", + "@quenty/promisemaid": "file:../promisemaid", + "@quenty/randomutils": "file:../randomutils", + "@quenty/rbxasset": "file:../rbxasset", + "@quenty/rx": "file:../rx", + "@quenty/signal": "file:../signal", + "@quenty/sounds": "file:../sounds", + "@quenty/table": "file:../table", + "@quenty/transitionmodel": "file:../transitionmodel", + "@quenty/valueobject": "file:../valueobject", + "@quentystudios/t": "^3.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/src/soundplayer/src/Client/Loops/Layered/LayeredLoopedSoundPlayer.lua b/src/soundplayer/src/Client/Loops/Layered/LayeredLoopedSoundPlayer.lua new file mode 100644 index 0000000000..46db1499b2 --- /dev/null +++ b/src/soundplayer/src/Client/Loops/Layered/LayeredLoopedSoundPlayer.lua @@ -0,0 +1,169 @@ +--[=[ + @class LayeredLoopedSoundPlayer +]=] + +local require = require(script.Parent.loader).load(script) + +local SpringTransitionModel = require("SpringTransitionModel") +local ValueObject = require("ValueObject") +local LoopedSoundPlayer = require("LoopedSoundPlayer") +local Maid = require("Maid") +local Rx = require("Rx") +local SoundUtils = require("SoundUtils") +local SoundLoopScheduleUtils = require("SoundLoopScheduleUtils") + +local LayeredLoopedSoundPlayer = setmetatable({}, SpringTransitionModel) +LayeredLoopedSoundPlayer.ClassName = "LayeredLoopedSoundPlayer" +LayeredLoopedSoundPlayer.__index = LayeredLoopedSoundPlayer + +function LayeredLoopedSoundPlayer.new(soundParent) + local self = setmetatable(SpringTransitionModel.new(), LayeredLoopedSoundPlayer) + + self._soundParent = ValueObject.new(nil) + self._maid:GiveTask(self._soundParent) + + self._bpm = ValueObject.new(nil) + self._maid:GiveTask(self._bpm) + + self._defaultCrossFadeTime = ValueObject.new(0.5, "number") + self._maid:GiveTask(self._defaultCrossFadeTime) + + self._layerMaid = Maid.new() + self._maid:GiveTask(self._layerMaid) + + self._volumeMultiplier = ValueObject.new(1, "number") + self._maid:GiveTask(self._volumeMultiplier) + + self._layers = {} + + if soundParent then + self:SetSoundParent(soundParent) + end + + return self +end + +function LayeredLoopedSoundPlayer:SetDefaultCrossFadeTime(crossFadeTime) + return self._defaultCrossFadeTime:Mount(crossFadeTime) +end + +function LayeredLoopedSoundPlayer:SetVolumeMultiplier(volumeMultiplier) + self._volumeMultiplier.Value = volumeMultiplier +end + +function LayeredLoopedSoundPlayer:SetBPM(bpm) + assert(type(bpm) == "number" or bpm == nil, "Bad bpm") + + self._bpm.Value = bpm +end + +function LayeredLoopedSoundPlayer:SetSoundParent(soundParent) + self._soundParent.Value = soundParent +end + +function LayeredLoopedSoundPlayer:Swap(layerId, soundId, scheduleOptions) + assert(type(layerId) == "string", 'Bad layerId') + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + assert(SoundLoopScheduleUtils.isLoopedSchedule(scheduleOptions) or scheduleOptions == nil, "Bad scheduleOptions") + + local layer = self:_getOrCreateLayer(layerId) + layer:Swap(soundId, scheduleOptions) +end + +function LayeredLoopedSoundPlayer:SwapOnLoop(layerId, soundId, scheduleOptions) + assert(type(layerId) == "string", 'Bad layerId') + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + + local layer = self:_getOrCreateLayer(layerId) + layer:SwapOnLoop(soundId, scheduleOptions) +end + +function LayeredLoopedSoundPlayer:SwapToSamples(layerId, soundId, scheduleOptions) + assert(type(layerId) == "string", 'Bad layerId') + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + assert(SoundLoopScheduleUtils.isLoopedSchedule(scheduleOptions) or scheduleOptions == nil, "Bad scheduleOptions") + + local layer = self:_getOrCreateLayer(layerId) + layer:SwapToSamples(soundId, scheduleOptions) +end + +function LayeredLoopedSoundPlayer:SwapToChoice(layerId, soundIdList, scheduleOptions) + assert(type(layerId) == "string", 'Bad layerId') + assert(type(soundIdList) == "table", "Bad soundIdList") + assert(SoundLoopScheduleUtils.isLoopedSchedule(scheduleOptions) or scheduleOptions == nil, "Bad scheduleOptions") + + local layer = self:_getOrCreateLayer(layerId) + layer:SwapToChoice(soundIdList, scheduleOptions) +end + +function LayeredLoopedSoundPlayer:PlayOnce(layerId, soundIdList, scheduleOptions) + assert(type(layerId) == "string", 'Bad layerId') + assert(type(soundIdList) == "table", "Bad soundIdList") + assert(SoundLoopScheduleUtils.isLoopedSchedule(scheduleOptions) or scheduleOptions == nil, "Bad scheduleOptions") + + local layer = self:_getOrCreateLayer(layerId) + layer:PlayOnce(soundIdList, scheduleOptions) +end + +function LayeredLoopedSoundPlayer:PlayOnceOnLoop(layerId, soundId, scheduleOptions) + assert(type(layerId) == "string", 'Bad layerId') + + local layer = self:_getOrCreateLayer(layerId) + layer:PlayOnceOnLoop(soundId, scheduleOptions) +end + +function LayeredLoopedSoundPlayer:_getOrCreateLayer(layerId) + if self._layers[layerId] then + return self._layers[layerId] + end + + local maid = Maid.new() + + local layer = LoopedSoundPlayer.new() + layer:SetDoSyncSoundPlayback(true) + maid:GiveTask(layer) + + maid:GiveTask(layer:SetCrossFadeTime(self._defaultCrossFadeTime:Observe())) + + maid:GiveTask(self._bpm:Observe():Subscribe(function(bpm) + layer:SetBPM(bpm) + end)) + + maid:GiveTask(self:ObserveVisible():Subscribe(function(isVisible, doNotAnimate) + layer:SetVisible(isVisible, doNotAnimate) + end)) + + maid:GiveTask(self._soundParent:Observe():Subscribe(function(parent) + layer:SetSoundParent(parent) + end)) + + maid:GiveTask(Rx.combineLatest({ + visible = self:ObserveRenderStepped(); + multiplier = self._volumeMultiplier:Observe(); + }):Subscribe(function(state) + layer:SetVolumeMultiplier(state.multiplier*state.visible) + end)) + + self._layers[layerId] = layer + maid:GiveTask(function() + if self._layers[layerId] == layer then + self._layers[layerId] = nil + end + end) + + self._layerMaid[layerId] = maid + + return layer +end + +function LayeredLoopedSoundPlayer:StopLayer(layerId) + assert(type(layerId) == "string", 'Bad layerId') + + self._layerMaid[layerId] = nil +end + +function LayeredLoopedSoundPlayer:StopAll() + self._layerMaid:DoCleaning() +end + +return LayeredLoopedSoundPlayer \ No newline at end of file diff --git a/src/soundplayer/src/Client/Loops/Layered/LayeredLoopedSoundPlayer.story.lua b/src/soundplayer/src/Client/Loops/Layered/LayeredLoopedSoundPlayer.story.lua new file mode 100644 index 0000000000..d2a6b8497f --- /dev/null +++ b/src/soundplayer/src/Client/Loops/Layered/LayeredLoopedSoundPlayer.story.lua @@ -0,0 +1,160 @@ +--[[ + @class LayeredLoopedSoundPlayer.story +]] + +local require = require(game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent).load(script) + +local Maid = require("Maid") +local LayeredLoopedSoundPlayer = require("LayeredLoopedSoundPlayer") +local Blend = require("Blend") + +return function(target) + local maid = Maid.new() + + local layeredLoopedSoundPlayer = LayeredLoopedSoundPlayer.new() + layeredLoopedSoundPlayer:SetSoundParent(target) + layeredLoopedSoundPlayer:SetBPM(95) + maid:GiveTask(layeredLoopedSoundPlayer) + + local function initial() + layeredLoopedSoundPlayer:SwapToChoice("drums", { + { + SoundId = "rbxassetid://14478151709"; + Volume = 0.1; + }; + { + SoundId = "rbxassetid://14478738244"; + Volume = 0.1; + } + }) + layeredLoopedSoundPlayer:SwapToChoice("rifts", { + { + SoundId = "rbxassetid://14478152812"; + Volume = 0.2; + }; + { + SoundId = "rbxassetid://14478729478"; + Volume = 0.015; + }; + }) + end + initial() + + layeredLoopedSoundPlayer:Show() + + local function button(props) + return Blend.New "TextButton" { + Text = props.Text; + AutoButtonColor = true; + Font = Enum.Font.FredokaOne; + Size = UDim2.new(0, 100, 0, 30); + + Blend.New "UICorner" { + + }; + + [Blend.OnEvent "Activated"] = function() + props.OnActivated(); + end; + }; + end + + maid:GiveTask(Blend.mount(target, { + Blend.New "Frame" { + Name = "ButtonContainer"; + BackgroundTransparency = 1; + Position = UDim2.new(0.5, 0, 0, 5); + AnchorPoint = Vector2.new(0.5, 0); + Size = UDim2.new(1, 0, 0, 30); + + Blend.New "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal; + Padding = UDim.new(0, 5); + HorizontalAlignment = Enum.HorizontalAlignment.Center; + }; + + button({ + Text = "Toggle"; + OnActivated = function() + layeredLoopedSoundPlayer:Toggle() + end; + }); + + button({ + Text = "Reset"; + OnActivated = function() + initial() + end; + }); + + button({ + Text = "Combat equip"; + OnActivated = function() + layeredLoopedSoundPlayer:SwapToChoice("drums", { + "rbxassetid://14478154829"; + "rbxassetid://14478714545"; + "rbxassetid://14478772830"; + "rbxassetid://14478897865"; + }) + layeredLoopedSoundPlayer:PlayOnceOnLoop("rifts", nil) + end; + }); + + button({ + Text = "On target lock"; + OnActivated = function() + layeredLoopedSoundPlayer:SwapToChoice("drums", { + { + SoundId = "rbxassetid://14478150956"; + Volume = 0.1; + }; + { + SoundId = "rbxassetid://14478721669"; + Volume = 0.2; + }; + "rbxassetid://14478154829"; + "rbxassetid://14478764914"; + }) + + layeredLoopedSoundPlayer:SwapToChoice("rifts", { + "rbxassetid://14478145963"; + "rbxassetid://14478156714"; + { + SoundId = "rbxassetid://14478777472"; + Volume = 0.1; + }; + { + SoundId = "rbxassetid://14478793045"; + Volume = 0.1; + }; + }) + end; + }); + + button({ + Text = "On low health"; + OnActivated = function() + layeredLoopedSoundPlayer:SwapToChoice("drums", { + "rbxassetid://14478746326"; + "rbxassetid://14478767498"; + "rbxassetid://14478797936"; -- record scratch + }) + + end; + }); + + + button({ + Text = "Target drop"; + OnActivated = function() + layeredLoopedSoundPlayer:PlayOnceOnLoop("rifts", "rbxassetid://14478158396") + end; + }); + } + })) + + + return function() + maid:DoCleaning() + end +end \ No newline at end of file diff --git a/src/soundplayer/src/Client/Loops/LoopedSoundPlayer.lua b/src/soundplayer/src/Client/Loops/LoopedSoundPlayer.lua new file mode 100644 index 0000000000..f7c1efec8a --- /dev/null +++ b/src/soundplayer/src/Client/Loops/LoopedSoundPlayer.lua @@ -0,0 +1,449 @@ +--[=[ + @class LoopedSoundPlayer +]=] + +local require = require(script.Parent.loader).load(script) + +local RunService = game:GetService("RunService") + +local Maid = require("Maid") +local Promise = require("Promise") +local PromiseMaidUtils = require("PromiseMaidUtils") +local RandomSampler = require("RandomSampler") +local RandomUtils = require("RandomUtils") +local Rx = require("Rx") +local RxInstanceUtils = require("RxInstanceUtils") +local Signal = require("Signal") +local SimpleLoopedSoundPlayer = require("SimpleLoopedSoundPlayer") +local SoundLoopScheduleUtils = require("SoundLoopScheduleUtils") +local SoundPromiseUtils = require("SoundPromiseUtils") +local SoundUtils = require("SoundUtils") +local SpringTransitionModel = require("SpringTransitionModel") +local ValueObject = require("ValueObject") + +local LoopedSoundPlayer = setmetatable({}, SpringTransitionModel) +LoopedSoundPlayer.ClassName = "LoopedSoundPlayer" +LoopedSoundPlayer.__index = LoopedSoundPlayer + +function LoopedSoundPlayer.new(soundId, soundParent) + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + + local self = setmetatable(SpringTransitionModel.new(), LoopedSoundPlayer) + + self._currentSoundLooped = Signal.new() + self._maid:GiveTask(self._currentSoundLooped) + + self._currentSoundLoopedAfterDelay = Signal.new() + self._maid:GiveTask(self._currentSoundLoopedAfterDelay) + + self:SetSpeed(10) + + self._bpm = ValueObject.new(nil) + self._maid:GiveTask(self._bpm) + + self._soundParent = ValueObject.new(nil) + self._maid:GiveTask(self._soundParent) + + self._crossFadeTime = ValueObject.new(0.5, "number") + self._maid:GiveTask(self._crossFadeTime) + + self._volumeMultiplier = ValueObject.new(1, "number") + self._maid:GiveTask(self._volumeMultiplier) + + self._doSyncSoundPlayback = ValueObject.new(false, "boolean") + self._maid:GiveTask(self._doSyncSoundPlayback) + + self._currentActiveSound = ValueObject.new(nil) + self._maid:GiveTask(self._currentActiveSound) + + self._currentSoundId = ValueObject.new(soundId) + self._maid:GiveTask(self._currentSoundId) + + self._defaultScheduleOptions = SoundLoopScheduleUtils.default() + + self._currentLoopSchedule = ValueObject.new(self._defaultScheduleOptions) + self._maid:GiveTask(self._currentLoopSchedule) + + if soundParent then + self:SetSoundParent(soundParent) + end + + if soundId then + self:Swap(soundId) + end + + self:_setupRender() + + return self +end + +function LoopedSoundPlayer:SetCrossFadeTime(crossFadeTime) + return self._crossFadeTime:Mount(crossFadeTime) +end + +function LoopedSoundPlayer:SetVolumeMultiplier(volume) + self._volumeMultiplier.Value = volume +end + +function LoopedSoundPlayer:SetBPM(bpm) + assert(type(bpm) == "number" or bpm == nil, "Bad bpm") + + self._bpm.Value = bpm +end + +function LoopedSoundPlayer:SetSoundParent(parent) + self._soundParent.Value = parent +end + +function LoopedSoundPlayer:Swap(soundId, loopSchedule) + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + loopSchedule = self:_convertToLoopedSchedule(loopSchedule) + + local maid = Maid.new() + + maid:GiveTask(self:_scheduleFirstPlay(loopSchedule, function() + self._currentLoopSchedule.Value = loopSchedule + self._currentSoundId.Value = soundId + end)) + + self._maid._swappingTo = maid +end + +function LoopedSoundPlayer:SetDoSyncSoundPlayback(doSyncSoundPlayback) + self._doSyncSoundPlayback.Value = doSyncSoundPlayback +end + +function LoopedSoundPlayer:_setupRender() + self._maid:GiveTask(self._currentSoundId:ObserveBrio(function(value) + return value ~= nil + end):Subscribe(function(brio) + if brio:IsDead() then + return + end + + + local maid = brio:ToMaid() + local soundId = brio:GetValue() + + maid:GiveTask(self:_renderSoundPlayer(soundId)) + end)) +end + +function LoopedSoundPlayer:_renderSoundPlayer(soundId) + local maid = Maid.new() + + local renderMaid = Maid.new() + local soundPlayer = SimpleLoopedSoundPlayer.new(soundId) + soundPlayer:SetTransitionTime(self._crossFadeTime) + renderMaid:GiveTask(soundPlayer) + + renderMaid:GiveTask(Rx.combineLatest({ + bpm = self._bpm:Observe(); + isLoaded = Rx.fromPromise(SoundPromiseUtils.promiseLoaded(soundPlayer.Sound)); + doSyncSoundPlayback = self._doSyncSoundPlayback:Observe(); + timeLength = RxInstanceUtils.observeProperty(soundPlayer.Sound, "TimeLength"); + }):Subscribe(function(state) + local syncMaid = Maid.new() + + if state.doSyncSoundPlayback then + if state.bpm then + local bps = state.bpm/60 + local beatTime = 1/bps + local truncatedTimeLength = math.floor(state.timeLength/beatTime) * beatTime + local currentTimePosition = soundPlayer.Sound.TimePosition + local clockDistanceIntoBeat = os.clock() % beatTime + local soundDistanceIntoBeat = currentTimePosition % beatTime + + -- Skip to next beat + local offset = (beatTime + (clockDistanceIntoBeat - soundDistanceIntoBeat)) % beatTime + soundPlayer.Sound.TimePosition = currentTimePosition + offset + + syncMaid:GiveTask(RunService.RenderStepped:Connect(function() + if soundPlayer.Sound.TimePosition > truncatedTimeLength then + soundPlayer.Sound.TimePosition = soundPlayer.Sound.TimePosition % truncatedTimeLength + + if self.Destroy then + if self._currentActiveSound.Value == soundPlayer.Sound then + self._currentSoundLooped:Fire() + end + end + end + end)) + else + soundPlayer.Sound.TimePosition = os.clock() % state.timeLength + end + end + + renderMaid._syncing = syncMaid + end)) + + maid:GiveTask(Rx.combineLatest({ + loopSchedule = self._currentLoopSchedule:Observe(); + }):Pipe({ + Rx.throttleDefer(); + }):Subscribe(function(state) + local scheduleMaid = Maid.new() + + scheduleMaid:GiveTask(self:_setupLoopScheduling(soundPlayer, state.loopSchedule)) + + renderMaid._loopMaid = scheduleMaid + end)) + + maid:GiveTask(soundPlayer.Sound.DidLoop:Connect(function() + self._currentSoundLooped:Fire() + end)) + + self._currentActiveSound.Value = soundPlayer.Sound + + maid:GiveTask(function() + if self._currentActiveSound.Value == soundPlayer.Sound then + self._currentActiveSound.Value = nil + end + end) + + renderMaid:GiveTask(self._soundParent:Observe():Subscribe(function(parent) + soundPlayer.Sound.Parent = parent + end)) + + maid:GiveTask(Rx.combineLatest({ + visible = self:ObserveRenderStepped(); + multiplier = self._volumeMultiplier:Observe(); + }):Subscribe(function(state) + soundPlayer:SetVolumeMultiplier(state.multiplier*state.visible) + end)) + + maid:GiveTask(self:ObserveVisible():Subscribe(function(isVisible, doNotAnimate) + soundPlayer:SetVisible(isVisible, doNotAnimate) + end)) + + maid:GiveTask(function() + soundPlayer:PromiseHide():Then(function() + renderMaid:Destroy() + end) + end) + + return maid +end + +function LoopedSoundPlayer:SetVolumeMultiplier(volume) + self._volumeMultiplier.Value = volume +end + +function LoopedSoundPlayer:_setupLoopScheduling(soundPlayer, loopSchedule) + local maid = Maid.new() + + if loopSchedule.maxLoops then + local loopCount = 0 + maid:GiveTask(self._currentSoundLooped:Connect(function() + loopCount = loopCount + 1 + + -- Cancel + if loopCount > loopSchedule.maxLoops then + self._currentSoundId.Value = nil + end + end)) + end + + if loopSchedule.loopDelay then + maid:GiveTask(self._currentSoundLooped:Connect(function() + local waitTime = SoundLoopScheduleUtils.getWaitTimeSeconds(loopSchedule.loopDelay) + + soundPlayer.Sound:Pause() + + maid._scheduled = task.delay(waitTime, function() + self._currentSoundLoopedAfterDelay:Fire() + soundPlayer.Sound:Play() + end) + end)) + else + maid:GiveTask(self._currentSoundLooped:Connect(function() + self._currentSoundLoopedAfterDelay:Fire() + end)) + end + + return maid +end + +function LoopedSoundPlayer:SwapToSamples(soundIdList, loopSchedule) + assert(type(soundIdList) == "table", "Bad soundIdList") + loopSchedule = self:_convertToLoopedSchedule(loopSchedule) + + local loopMaid = Maid.new() + + loopMaid:GiveTask(self:_scheduleFirstPlay(loopSchedule, function() + local sampler = RandomSampler.new(soundIdList) + self._currentLoopSchedule.Value = loopSchedule + self._currentSoundId.Value = sampler:Sample() + + loopMaid:GiveTask(self._currentSoundLoopedAfterDelay:Connect(function() + self._currentSoundId.Value = sampler:Sample() + end)) + end)) + + self._maid._swappingTo = loopMaid +end + +function LoopedSoundPlayer:SwapToChoice(soundIdList, loopSchedule) + assert(type(soundIdList) == "table", "Bad soundIdList") + loopSchedule = self:_convertToLoopedSchedule(loopSchedule) + + local loopMaid = Maid.new() + + loopMaid:GiveTask(self:_scheduleFirstPlay(loopSchedule, function() + self._currentLoopSchedule.Value = loopSchedule + self._currentSoundId.Value = RandomUtils.choice(soundIdList) + + loopMaid:GiveTask(self._currentSoundLoopedAfterDelay:Connect(function() + self._currentSoundId.Value = RandomUtils.choice(soundIdList) + end)) + end)) + + self._maid._swappingTo = loopMaid +end + +function LoopedSoundPlayer:PlayOnce(soundId, loopSchedule) + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + loopSchedule = self:_convertToLoopedSchedule(loopSchedule) + + self:Swap(soundId, SoundLoopScheduleUtils.maxLoops(1, loopSchedule)) +end + +function LoopedSoundPlayer:SwapOnLoop(soundId, loopSchedule) + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + loopSchedule = self:_convertToLoopedSchedule(loopSchedule) + + self:Swap(soundId, SoundLoopScheduleUtils.onNextLoop(loopSchedule)) +end + +function LoopedSoundPlayer:PlayOnceOnLoop(soundId, loopSchedule) + assert(SoundUtils.isConvertableToRbxAsset(soundId) or soundId == nil, "Bad soundId") + loopSchedule = self:_convertToLoopedSchedule(loopSchedule) + + self:PlayOnce(soundId, SoundLoopScheduleUtils.onNextLoop(loopSchedule)) +end + +function LoopedSoundPlayer:_convertToLoopedSchedule(loopSchedule) + assert(SoundLoopScheduleUtils.isLoopedSchedule(loopSchedule) or loopSchedule == nil, "Bad loopSchedule") + return loopSchedule or self._defaultScheduleOptions +end + +function LoopedSoundPlayer:_scheduleFirstPlay(loopSchedule, callback) + assert(SoundLoopScheduleUtils.isLoopedSchedule(loopSchedule), "Bad loopSchedule") + assert(type(callback) == "function", "Bad callback") + + local maid = Maid.new() + + local observable = Rx.of(true) + if loopSchedule.playOnNextLoop then + observable = observable:Pipe({ + Rx.switchMap(function() + local waitTime = nil + if loopSchedule.maxInitialWaitTimeForNextLoop then + waitTime = SoundLoopScheduleUtils.getWaitTimeSeconds(loopSchedule.maxInitialWaitTimeForNextLoop) + end + + return self:_observeActiveSoundFinishLoop(waitTime) + end); + }); + end + + if loopSchedule.initialDelay then + observable = observable:Pipe({ + Rx.switchMap(function() + return Rx.delayed(SoundLoopScheduleUtils.getWaitTimeSeconds(loopSchedule.initialDelay)) + end); + }); + end + + -- Immediate + if observable then + maid._observeOnce = observable:Subscribe(function() + maid._observeOnce = nil + callback() + end) + else + callback() + end + + return maid +end + +function LoopedSoundPlayer:StopAfterLoop() + local swapMaid = Maid.new() + + swapMaid:GiveTask(self._currentSoundLooped:Connect(function() + if self._maid._swappingTo == swapMaid then + self._currentSoundId.Value = nil + end + end)) + + self._maid._swappingTo = swapMaid +end + +function LoopedSoundPlayer:_observeActiveSoundFinishLoop(maxWaitTime) + local startTime = os.clock() + + return self._currentActiveSound:Observe():Pipe({ + Rx.throttleDefer(); + Rx.switchMap(function(sound) + if not sound then + return Rx.of(true) + end + + return Rx.combineLatest({ + timeLength = RxInstanceUtils.observeProperty(sound, "TimeLength"); + timePosition = RxInstanceUtils.observeProperty(sound, "TimePosition"); + crossFadeTime = self._crossFadeTime:Observe(); + }):Pipe({ + Rx.switchMap(function(state) + local timeElapsed = os.clock() - startTime + local timeRemaining + if maxWaitTime then + timeRemaining = maxWaitTime - timeElapsed + end + + -- We assume it's gonna load + if state.timeLength == 0 then + if timeRemaining then + return Rx.delayed(timeRemaining) + else + return Rx.EMPTY + end + end + + local waitTime = state.timeLength - state.timePosition - state.crossFadeTime + + if timeRemaining then + waitTime = math.min(waitTime, timeRemaining) + end + + return Rx.delayed(waitTime) + end); + }) + end) + }) +end + +function LoopedSoundPlayer:PromiseLoopDone() + local promise = self._maid:GivePromise(Promise.new()) + + PromiseMaidUtils.whilePromise(promise, function(maid) + maid:GiveTask(self._currentSoundLooped:Connect(function() + promise:Resolve() + end)) + end) + + return promise +end + +function LoopedSoundPlayer:PromiseSustain() + -- Never resolve (?) + return Promise.new() +end + + +function LoopedSoundPlayer:GetSound() + return self._sound +end + +return LoopedSoundPlayer \ No newline at end of file diff --git a/src/soundplayer/src/Client/Loops/LoopedSoundPlayer.story.lua b/src/soundplayer/src/Client/Loops/LoopedSoundPlayer.story.lua new file mode 100644 index 0000000000..4d376a1d18 --- /dev/null +++ b/src/soundplayer/src/Client/Loops/LoopedSoundPlayer.story.lua @@ -0,0 +1,125 @@ +--[[ + @class LoopedSoundPlayer.story +]] + +local require = require(game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent).load(script) + +local Maid = require("Maid") +local LoopedSoundPlayer = require("LoopedSoundPlayer") +local RandomUtils = require("RandomUtils") +local Blend = require("Blend") +local LoopedSoundScheduleUtils = require("LoopedSoundScheduleUtils") + +return function(target) + local maid = Maid.new() + + local ORIGINAL = nil --"rbxassetid://14477435416" + + local loopedSoundPlayer = LoopedSoundPlayer.new(ORIGINAL, target) + loopedSoundPlayer:SetDoSyncSoundPlayback(true) + loopedSoundPlayer:SetCrossFadeTime(2) + loopedSoundPlayer:SetVolumeMultiplier(0.25) + loopedSoundPlayer:SetSoundParent(target) + maid:GiveTask(loopedSoundPlayer) + + local OPTIONS = { + "rbxassetid://14477453689"; + } + + maid:GiveTask(task.spawn(function() + while true do + task.wait(2) + -- loopedSoundPlayer:Swap(RandomUtils.choice(OPTIONS)) + end + end)) + + loopedSoundPlayer:Show() + + local function button(props) + return Blend.New "TextButton" { + Text = props.Text; + AutoButtonColor = true; + Font = Enum.Font.FredokaOne; + Size = UDim2.new(0, 100, 0, 30); + + Blend.New "UICorner" { + + }; + + [Blend.OnEvent "Activated"] = function() + props.OnActivated(); + end; + }; + end + + maid:GiveTask(Blend.mount(target, { + Blend.New "Frame" { + Name = "ButtonContainer"; + BackgroundTransparency = 1; + Position = UDim2.new(0.5, 0, 0, 5); + AnchorPoint = Vector2.new(0.5, 0); + Size = UDim2.new(1, 0, 0, 30); + + Blend.New "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal; + Padding = UDim.new(0, 5); + HorizontalAlignment = Enum.HorizontalAlignment.Center; + }; + + button({ + Text = "Toggle"; + OnActivated = function() + loopedSoundPlayer:Toggle() + end; + }); + + button({ + Text = "Reset"; + OnActivated = function() + loopedSoundPlayer:Swap(ORIGINAL) + end; + }); + + button({ + Text = "Swap sample"; + OnActivated = function() + loopedSoundPlayer:SwapToSamples({ + "rbxassetid://14478670277"; + "rbxassetid://14478671494"; + "rbxassetid://14478672676"; + }) + end; + }); + + button({ + Text = "Play once"; + OnActivated = function() + loopedSoundPlayer:PlayOnce("rbxassetid://14478764914") + end; + }); + + button({ + Text = "Play delayed loop"; + OnActivated = function() + loopedSoundPlayer:Swap({ + SoundId ="rbxassetid://6052547865"; + Volume = 3; + }, LoopedSoundScheduleUtils.schedule({ + loopDelay = NumberRange.new(0.25, 1); + })) + end; + }); + + button({ + Text = "Swap on loop"; + OnActivated = function() + loopedSoundPlayer:SwapOnLoop(RandomUtils.choice(OPTIONS)) + end; + }); + } + })) + + return function() + maid:DoCleaning() + end +end \ No newline at end of file diff --git a/src/soundplayer/src/Client/Loops/SimpleLoopedSoundPlayer.lua b/src/soundplayer/src/Client/Loops/SimpleLoopedSoundPlayer.lua new file mode 100644 index 0000000000..71ff0e3b89 --- /dev/null +++ b/src/soundplayer/src/Client/Loops/SimpleLoopedSoundPlayer.lua @@ -0,0 +1,63 @@ +--[=[ + @class SimpleLoopedSoundPlayer +]=] + +local require = require(script.Parent.loader).load(script) + +local TimedTransitionModel = require("TimedTransitionModel") +local Rx = require("Rx") +local SoundUtils = require("SoundUtils") +local SoundPromiseUtils = require("SoundPromiseUtils") +local Promise = require("Promise") +local ValueObject = require("ValueObject") + +local SimpleLoopedSoundPlayer = setmetatable({}, TimedTransitionModel) +SimpleLoopedSoundPlayer.ClassName = "SimpleLoopedSoundPlayer" +SimpleLoopedSoundPlayer.__index = SimpleLoopedSoundPlayer + +function SimpleLoopedSoundPlayer.new(soundId) + local self = setmetatable(TimedTransitionModel.new(), SimpleLoopedSoundPlayer) + + self.Sound = SoundUtils.createSoundFromId(soundId) + self.Sound.Looped = true + self.Sound.Archivable = false + self._maid:GiveTask(self.Sound) + + self:SetTransitionTime(1) + + self._volumeMultiplier = ValueObject.new(1, "number") + self._maid:GiveTask(self._volumeMultiplier) + + self._maxVolume = self.Sound.Volume + + self._maid:GiveTask(Rx.combineLatest({ + visible = self:ObserveRenderStepped(); + multiplier = self._volumeMultiplier:Observe(); + }):Subscribe(function(state) + self.Sound.Volume = state.visible*self._maxVolume*state.multiplier + end)) + + self._maid:GiveTask(self.VisibleChanged:Connect(function(isVisible) + if isVisible then + self.Sound:Play() + end + end)) + + return self +end + +function SimpleLoopedSoundPlayer:SetVolumeMultiplier(volume) + self._volumeMultiplier.Value = volume +end + +function SimpleLoopedSoundPlayer:PromiseSustain() + -- Never resolve + return Promise.new() +end + +function SimpleLoopedSoundPlayer:PromiseLoopDone() + return SoundPromiseUtils.promiseLooped(self.Sound) +end + + +return SimpleLoopedSoundPlayer \ No newline at end of file diff --git a/src/soundplayer/src/Client/Loops/SimpleLoopedSoundPlayer.story.lua b/src/soundplayer/src/Client/Loops/SimpleLoopedSoundPlayer.story.lua new file mode 100644 index 0000000000..0dbd090a54 --- /dev/null +++ b/src/soundplayer/src/Client/Loops/SimpleLoopedSoundPlayer.story.lua @@ -0,0 +1,68 @@ +--[[ + @class SimpleLoopedSoundPlayer.story +]] + +local require = require(game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent).load(script) + +local Maid = require("Maid") +local ServiceBag = require("ServiceBag") +local SimpleLoopedSoundPlayer = require("SimpleLoopedSoundPlayer") +local Blend = require("Blend") + +return function(target) + local maid = Maid.new() + local serviceBag = ServiceBag.new() + maid:GiveTask(serviceBag) + + local simpleLoopedSoundPlayer = SimpleLoopedSoundPlayer.new("rbxassetid://14477453689") + simpleLoopedSoundPlayer:SetTransitionTime(1) + maid:GiveTask(simpleLoopedSoundPlayer) + + simpleLoopedSoundPlayer.Sound.Parent = target + + simpleLoopedSoundPlayer:Show() + + local function button(props) + return Blend.New "TextButton" { + Text = props.Text; + AutoButtonColor = true; + Font = Enum.Font.FredokaOne; + Size = UDim2.new(0, 100, 0, 30); + + Blend.New "UICorner" { + + }; + + [Blend.OnEvent "Activated"] = function() + props.OnActivated(); + end; + }; + end + + maid:GiveTask(Blend.mount(target, { + Blend.New "Frame" { + Name = "ButtonContainer"; + BackgroundTransparency = 1; + Position = UDim2.new(0.5, 0, 0, 5); + AnchorPoint = Vector2.new(0.5, 0); + Size = UDim2.new(1, 0, 0, 30); + + Blend.New "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal; + Padding = UDim.new(0, 5); + HorizontalAlignment = Enum.HorizontalAlignment.Center; + }; + + button({ + Text = "Toggle"; + OnActivated = function() + simpleLoopedSoundPlayer:Toggle() + end; + }); + } + })) + + return function() + maid:DoCleaning() + end +end \ No newline at end of file diff --git a/src/soundplayer/src/Client/Schedule/SoundLoopScheduleUtils.lua b/src/soundplayer/src/Client/Schedule/SoundLoopScheduleUtils.lua new file mode 100644 index 0000000000..3cbe885315 --- /dev/null +++ b/src/soundplayer/src/Client/Schedule/SoundLoopScheduleUtils.lua @@ -0,0 +1,64 @@ +--[=[ + @class SoundLoopScheduleUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local t = require("t") +local NumberRangeUtils = require("NumberRangeUtils") +local Table = require("Table") + +local SoundLoopScheduleUtils = {} + +function SoundLoopScheduleUtils.schedule(loopedSchedule) + assert(SoundLoopScheduleUtils.isLoopedSchedule(loopedSchedule)) + + return table.freeze(loopedSchedule) +end + +function SoundLoopScheduleUtils.onNextLoop(loopedSchedule) +assert(SoundLoopScheduleUtils.isLoopedSchedule(loopedSchedule) or loopedSchedule == nil, "Bad loopedSchedule") + + loopedSchedule = loopedSchedule or {} + return SoundLoopScheduleUtils.schedule(Table.merge(loopedSchedule, { + playOnNextLoop = true; + })) +end + +function SoundLoopScheduleUtils.maxLoops(maxLoops, loopedSchedule) + assert(type(maxLoops) == "number", "Bad maxLoops") + assert(SoundLoopScheduleUtils.isLoopedSchedule(loopedSchedule) or loopedSchedule == nil, "Bad loopedSchedule") + + loopedSchedule = loopedSchedule or {} + return SoundLoopScheduleUtils.schedule(Table.merge(loopedSchedule, { + maxLoops = maxLoops; + })) +end + +function SoundLoopScheduleUtils.default() + return SoundLoopScheduleUtils.schedule({}) +end + +SoundLoopScheduleUtils.isWaitTimeSeconds = t.union(t.number, t.NumberRange) + +SoundLoopScheduleUtils.isLoopedSchedule = t.interface({ + playOnNextLoop = t.optional(t.boolean); + maxLoops = t.optional(t.number); + initialDelay = t.optional(SoundLoopScheduleUtils.isWaitTimeSeconds); + loopDelay = t.optional(SoundLoopScheduleUtils.isWaitTimeSeconds); + maxInitialWaitTimeForNextLoop = t.optional(SoundLoopScheduleUtils.isWaitTimeSeconds); +}) + +function SoundLoopScheduleUtils.getWaitTimeSeconds(waitTime) + assert(SoundLoopScheduleUtils.isWaitTimeSeconds(waitTime)) + + if type(waitTime) == "number" then + return waitTime + elseif typeof(waitTime) == "NumberRange" then + return NumberRangeUtils.getValue(waitTime, math.random()) + else + error("Bad waitTime") + end +end + +return SoundLoopScheduleUtils \ No newline at end of file diff --git a/src/soundplayer/src/node_modules.project.json b/src/soundplayer/src/node_modules.project.json new file mode 100644 index 0000000000..46233dac4f --- /dev/null +++ b/src/soundplayer/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/src/soundplayer/test/default.project.json b/src/soundplayer/test/default.project.json new file mode 100644 index 0000000000..8abefcd753 --- /dev/null +++ b/src/soundplayer/test/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "SoundPlayerTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "soundplayer": { + "$path": ".." + } + } + } +} \ No newline at end of file diff --git a/src/sounds/package.json b/src/sounds/package.json index 41db37e5bb..e25c00769e 100644 --- a/src/sounds/package.json +++ b/src/sounds/package.json @@ -27,7 +27,9 @@ ], "dependencies": { "@quenty/loader": "file:../loader", + "@quenty/maid": "file:../maid", "@quenty/promise": "file:../promise", + "@quenty/promisemaid": "file:../promisemaid", "@quenty/rbxasset": "file:../rbxasset" }, "publishConfig": { diff --git a/src/sounds/src/Shared/SoundPromiseUtils.lua b/src/sounds/src/Shared/SoundPromiseUtils.lua index 9be40b17c5..21378f424c 100644 --- a/src/sounds/src/Shared/SoundPromiseUtils.lua +++ b/src/sounds/src/Shared/SoundPromiseUtils.lua @@ -7,6 +7,8 @@ local require = require(script.Parent.loader).load(script) local Promise = require("Promise") local PromiseUtils = require("PromiseUtils") +local PromiseMaidUtils = require("PromiseMaidUtils") +local Maid = require("Maid") local SoundPromiseUtils = {} @@ -21,22 +23,44 @@ function SoundPromiseUtils.promiseLoaded(sound) end local promise = Promise.new() + local maid = Maid.new() - local conn + maid:GiveTask(sound:GetPropertyChangedSignal("IsLoaded"):Connect(function() + if sound.IsLoaded then + promise:Resolve() + end + end)) - conn = sound.Loaded:Connect(function() + maid:GiveTask(sound.Loaded:Connect(function() if sound.IsLoaded then promise:Resolve() end - end) + end)) promise:Finally(function() - conn:Disconnect() + maid:DoCleaning() end) return promise end +function SoundPromiseUtils.promisePlayed(sound) + return SoundPromiseUtils.promiseLoaded(sound):Then(function() + return PromiseUtils.delayed(sound.TimeLength) + end) +end + +function SoundPromiseUtils.promiseLooped(sound) + local promise = Promise.new() + + PromiseMaidUtils.whilePromise(promise, function(maid) + maid:GiveTask(sound.DidLoop:Connect(function() + promise:Resolve() + end)) + end) + + return promise +end --[=[ Promises that all sounds are loaded @param sounds { Sound } diff --git a/src/sounds/src/Shared/SoundUtils.lua b/src/sounds/src/Shared/SoundUtils.lua index a5bb396a8a..36a5501fe4 100644 --- a/src/sounds/src/Shared/SoundUtils.lua +++ b/src/sounds/src/Shared/SoundUtils.lua @@ -29,13 +29,9 @@ local SoundUtils = {} The sound will be automatically cleaned up after the sound is played. ::: - @param id string | number @return Sound ]=] -function SoundUtils.playFromId(id: string | number): Sound - local soundId = RbxAssetUtils.toRbxAssetId(id) - assert(type(soundId) == "string", "Bad id") - +function SoundUtils.playFromId(id: string | number | table): Sound local sound = SoundUtils.createSoundFromId(id) if RunService:IsClient() then @@ -52,34 +48,52 @@ end --[=[ Creates a new sound object from the given id ]=] -function SoundUtils.createSoundFromId(id: string | number): Sound - local soundId = RbxAssetUtils.toRbxAssetId(id) +function SoundUtils.createSoundFromId(id: string | number | table): Sound + local soundId = SoundUtils.toRbxAssetId(id) assert(type(soundId) == "string", "Bad id") local sound = Instance.new("Sound") + sound.Archivable = false + + SoundUtils.applyPropertiesFromId(sound, id) + + return sound +end + +function SoundUtils.applyPropertiesFromId(sound, id) + local soundId = SoundUtils.toRbxAssetId(id) sound.Name = ("Sound_%s"):format(soundId) sound.SoundId = soundId sound.RollOffMode = Enum.RollOffMode.InverseTapered sound.Volume = 0.25 - sound.Archivable = false - return sound + if type(id) == "table" then + for property, value in pairs(id) do + if property ~= "Parent" and property ~= "RollOffMinDistance" then + sound[property] = value + end + end + + if id.RollOffMinDistance then + sound.RollOffMinDistance = id.RollOffMinDistance + end + + if id.Parent then + sound.Parent = id.Parent + end + end end --[=[ Plays back a template given asset id in the parent ]=] -function SoundUtils.playFromIdInParent(id: string | number, parent: Instance): Sound +function SoundUtils.playFromIdInParent(id: string | number | table, parent: Instance): Sound assert(typeof(parent) == "Instance", "Bad parent") local sound = SoundUtils.createSoundFromId(id) sound.Parent = parent - if not RunService:IsRunning() then - SoundService:PlayLocalSound(sound) - else - sound:Play() - end + sound:Play() SoundUtils.removeAfterTimeLength(sound) @@ -130,14 +144,26 @@ end --[=[ Converts a string or number to a string for playback. - Alias of [RbxAssetUtils.toRbxAssetId] for backwards compatibility. - @function toRbxAssetId @param id string? | number @return string? @within SoundUtils ]=] -SoundUtils.toRbxAssetId = RbxAssetUtils.toRbxAssetId +function SoundUtils.toRbxAssetId(soundId) + if type(soundId) == "table" then + return RbxAssetUtils.toRbxAssetId(soundId.SoundId) + else + return RbxAssetUtils.toRbxAssetId(soundId) + end +end + +function SoundUtils.isConvertableToRbxAsset(soundId) + if type(soundId) == "table" then + return RbxAssetUtils.isConvertableToRbxAsset(soundId.SoundId) + else + return RbxAssetUtils.isConvertableToRbxAsset(soundId) + end +end --[=[ Plays back a sound template in a specific parent. diff --git a/src/table/src/Shared/Set.lua b/src/table/src/Shared/Set.lua index 8ef7495b7f..5311de910a 100644 --- a/src/table/src/Shared/Set.lua +++ b/src/table/src/Shared/Set.lua @@ -64,6 +64,19 @@ function Set.copy(set) return newSet end +--[=[ + Makes a new set from the given keys of a table + @param tab table + @return table +]=] +function Set.fromKeys(tab) + local newSet = {} + for key, _ in pairs(tab) do + newSet[key] = true + end + return newSet +end + --[=[ Converts a set from table values. @param tab table diff --git a/src/table/src/Shared/Table.lua b/src/table/src/Shared/Table.lua index f0b1c3462b..a3a49c2114 100644 --- a/src/table/src/Shared/Table.lua +++ b/src/table/src/Shared/Table.lua @@ -28,10 +28,7 @@ end @return table ]=] function Table.merge(orig, new) - local result = {} - for key, val in pairs(orig) do - result[key] = val - end + local result = table.clone(orig) for key, val in pairs(new) do result[key] = val end @@ -155,11 +152,11 @@ Table.copy = table.clone Deep copies a table including metatables @param target table -- Table to deep copy - @param _context table? -- Cntext to deepCopy the value in + @param _context table? -- Context to deepCopy the value in @return table -- Result ]=] function Table.deepCopy(target, _context) - _context = _context or {} + _context = _context or {} if _context[target] then return _context[target] end @@ -266,6 +263,42 @@ function Table.overwrite(target, source) return target end +--[=[ + Deep equivalent comparison of a table assuming keys are indexable in the same way. + + @param target table -- Table to check + @param source table -- Other table to check + @return boolean +]=] +function Table.deepEquivalent(target, source) + if target == source then + return true + end + + if type(target) ~= type(source) then + return false + end + + if type(target) == "table" then + for key, value in pairs(target) do + if not Table.deepEquivalent(value, source[key]) then + return false + end + end + + for key, value in pairs(source) do + if not Table.deepEquivalent(value, target[key]) then + return false + end + end + + return true + else + -- target == source should do it. + return false + end +end + --[=[ Takes `count` entries from the table. If the table does not have that many entries, will return up to the number the table has to diff --git a/src/time/src/Shared/Time.lua b/src/time/src/Shared/Time.lua index e708137872..827758c755 100644 --- a/src/time/src/Shared/Time.lua +++ b/src/time/src/Shared/Time.lua @@ -24,10 +24,7 @@ local DAYS_OF_WEEK_SHORT = {"Sun", "Mon", "Tues", "Weds", "Thurs", @return { [number]: number } ]=] function Time.getDaysMonthTable(year) - local copy = {} - for key, value in pairs(DAYS_IN_MONTH) do - copy[key] = value - end + local copy = table.clone(DAYS_IN_MONTH) if year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) then copy[2] = 29 diff --git a/src/timedtween/README.md b/src/timedtween/README.md new file mode 100644 index 0000000000..6a1489e6cb --- /dev/null +++ b/src/timedtween/README.md @@ -0,0 +1,23 @@ +## TimedTween + + + +Linear timed tweening model + + + +## Installation + +``` +npm install @quenty/timedtween --save +``` diff --git a/src/timedtween/default.project.json b/src/timedtween/default.project.json new file mode 100644 index 0000000000..3782fc64db --- /dev/null +++ b/src/timedtween/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "timedtween", + "tree": { + "$path": "src" + } +} diff --git a/src/timedtween/package.json b/src/timedtween/package.json new file mode 100644 index 0000000000..700f1367ce --- /dev/null +++ b/src/timedtween/package.json @@ -0,0 +1,41 @@ +{ + "name": "@quenty/timedtween", + "version": "1.0.0", + "description": "Linear timed tweening model", + "keywords": [ + "Roblox", + "Nevermore", + "Lua", + "timedtween" + ], + "bugs": { + "url": "https://github.com/Quenty/NevermoreEngine/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Quenty/NevermoreEngine.git", + "directory": "src/timedtween/" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/quenty" + }, + "license": "MIT", + "contributors": [ + "Quenty" + ], + "dependencies": { + "@quenty/basicpane": "file:../basicpane", + "@quenty/blend": "file:../blend", + "@quenty/loader": "file:../loader", + "@quenty/math": "file:../math", + "@quenty/promise": "file:../promise", + "@quenty/promisemaid": "file:../promisemaid", + "@quenty/rx": "file:../rx", + "@quenty/steputils": "file:../steputils", + "@quenty/valueobject": "file:../valueobject" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/src/timedtween/src/Shared/TimedTween.lua b/src/timedtween/src/Shared/TimedTween.lua new file mode 100644 index 0000000000..fe62e90a77 --- /dev/null +++ b/src/timedtween/src/Shared/TimedTween.lua @@ -0,0 +1,158 @@ +--[=[ + @class TimedTween +]=] + +local require = require(script.Parent.loader).load(script) + +local RunService = game:GetService("RunService") + +local BasicPane = require("BasicPane") +local ValueObject = require("ValueObject") +local Math = require("Math") +local StepUtils = require("StepUtils") +local Observable = require("Observable") +local Maid = require("Maid") +local Promise = require("Promise") + +local TimedTween = setmetatable({}, BasicPane) +TimedTween.ClassName = "TimedTween" +TimedTween.__index = TimedTween + +function TimedTween.new(transitionTime) + local self = setmetatable(BasicPane.new(), TimedTween) + + self._transitionTime = self._maid:Add(ValueObject.new(0.15, "number")) + self._state = self._maid:Add(ValueObject.new({ + p0 = 0; + p1 = 0; + t0 = 0; + t1 = 0; + })) + + if transitionTime then + self:SetTransitionTime(transitionTime) + end + + self._maid:GiveTask(self._transitionTime.Changed:Connect(function() + self:_updateState() + end)) + + self._maid:GiveTask(self.VisibleChanged:Connect(function() + self:_updateState() + end)) + self:_updateState() + + return self +end + +function TimedTween:SetTransitionTime(transitionTime) + return self._transitionTime:Mount(transitionTime) +end + +function TimedTween:ObserveRenderStepped() + return self:ObserveOnSignal(RunService.RenderStepped) +end + +function TimedTween:ObserveOnSignal(signal) + return Observable.new(function(sub) + local maid = Maid.new() + + local startAnimate, stopAnimate = StepUtils.bindToSignal(signal, function() + local state = self:_computeState(os.clock()) + sub:Fire(state.p) + return state.rtime > 0 + end) + + maid:GiveTask(stopAnimate) + maid:GiveTask(self._state.Changed:Connect(startAnimate)) + startAnimate() + + return maid + end) +end + +function TimedTween:Observe() + return self:ObserveOnSignal(RunService.RenderStepped) +end + +function TimedTween:PromiseFinished() + local initState = self:_computeState(os.clock()) + if initState.rtime <= 0 then + return Promise.resolved() + end + + local maid = Maid.new() + local promise = Promise.new() + maid:GiveTask(promise) + + maid:GiveTask(self._state:Observe():Subscribe(function() + local state = self:_computeState(os.clock()) + if state.rtime <= 0 then + promise:Resolve() + return + end + + maid._scheduled = task.delay(state.rtime, function() + promise:Resolve() + end) + end)) + + self._maid[promise] = maid + + promise:Finally(function() + self._maid[promise] = nil + end) + + maid:GiveTask(function() + self._maid[promise] = nil + end) + return promise +end + +function TimedTween:_updateState() + local transitionTime = self._transitionTime.Value + local target = self:IsVisible() and 1 or 0; + + local now = os.clock() + local computed = self:_computeState(now) + local p0 = computed.p + + local remainingDist = target - p0 + + self._state.Value = { + p0 = p0; + p1 = target; + t0 = now; + t1 = now + Math.map(math.abs(remainingDist), 0, 1, 0, transitionTime); + } +end + +function TimedTween:_computeState(now) + local state = self._state.Value + local p + + local duration = math.max(0, state.t1 - state.t0) + if duration == 0 then + p = state.p1 + else + p = Math.map(math.clamp(now, state.t0, state.t1), state.t0, state.t1, state.p0, state.p1) + end + + local rtime = math.abs(state.p1 - p)*duration + + local v + if rtime > 0 and duration > 0 then + v = (state.p1 - state.p0)/duration + else + v = 0 + end + + return { + p = p; + v = v; + rtime = rtime; + } +end + + +return TimedTween \ No newline at end of file diff --git a/src/timedtween/src/Shared/TimedTween.story.lua b/src/timedtween/src/Shared/TimedTween.story.lua new file mode 100644 index 0000000000..ee29701007 --- /dev/null +++ b/src/timedtween/src/Shared/TimedTween.story.lua @@ -0,0 +1,34 @@ +--[[ + @class TimedTween.story +]] + +local require = require(game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent).load(script) + +local Maid = require("Maid") +local TimedTween = require("TimedTween") +local Blend = require("Blend") + +return function(target) + local maid = Maid.new() + + local timedTween = TimedTween.new(0.3) + maid:GiveTask(timedTween) + + + maid:GiveTask(Blend.mount(target, { + Blend.New "TextButton" { + Size = UDim2.fromScale(1, 1); + BackgroundTransparency = Blend.Computed(timedTween:Observe(), function(position) + return 1 - position + end); + + [Blend.OnEvent "Activated"] = function() + timedTween:Toggle() + end; + }; + })); + + return function() + maid:DoCleaning() + end +end \ No newline at end of file diff --git a/src/timedtween/src/node_modules.project.json b/src/timedtween/src/node_modules.project.json new file mode 100644 index 0000000000..46233dac4f --- /dev/null +++ b/src/timedtween/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/src/timedtween/test/default.project.json b/src/timedtween/test/default.project.json new file mode 100644 index 0000000000..a7e2bf2c22 --- /dev/null +++ b/src/timedtween/test/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "TimedTweenTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "timedtween": { + "$path": ".." + } + } + } +} \ No newline at end of file diff --git a/src/transitionmodel/package.json b/src/transitionmodel/package.json index 7fd51f431a..58002eabf4 100644 --- a/src/transitionmodel/package.json +++ b/src/transitionmodel/package.json @@ -34,6 +34,7 @@ "@quenty/promise": "file:../promise", "@quenty/rx": "file:../rx", "@quenty/signal": "file:../signal", + "@quenty/timedtween": "file:../timedtween", "@quenty/valueobject": "file:../valueobject" }, "publishConfig": { diff --git a/src/transitionmodel/src/Shared/Timed/TimedTransitionModel.lua b/src/transitionmodel/src/Shared/Timed/TimedTransitionModel.lua new file mode 100644 index 0000000000..976c6d6465 --- /dev/null +++ b/src/transitionmodel/src/Shared/Timed/TimedTransitionModel.lua @@ -0,0 +1,180 @@ +--[=[ + @class TimedTransitionModel +]=] + +local require = require(script.Parent.loader).load(script) + +local BasicPane = require("BasicPane") +local TransitionModel = require("TransitionModel") +local TimedTween = require("TimedTween") +local Promise = require("Promise") +local Maid = require("Maid") + +local TimedTransitionModel = setmetatable({}, BasicPane) +TimedTransitionModel.ClassName = "TimedTransitionModel" +TimedTransitionModel.__index = TimedTransitionModel + +--[=[ + A transition model that has a spring underlying it. Very useful + for animations on tracks that need to be on a spring. + + @return TimedTransitionModel +]=] +function TimedTransitionModel.new() + local self = setmetatable(BasicPane.new(), TimedTransitionModel) + + self._transitionModel = TransitionModel.new() + self._transitionModel:BindToPaneVisbility(self) + self._maid:GiveTask(self._transitionModel) + + + self._timedTween = TimedTween.new(0.15) + self._maid:GiveTask(self._timedTween) + + -- State + self._transitionModel:SetPromiseShow(function(maid, doNotAnimate) + return self:_promiseShow(maid, doNotAnimate) + end) + self._transitionModel:SetPromiseHide(function(maid, doNotAnimate) + return self:_promiseHide(maid, doNotAnimate) + end) + + return self +end + +function TimedTransitionModel:SetTransitionTime(transitionTime) + self._timedTween:SetTransitionTime(transitionTime) +end + +--[=[ + Returns true if showing is complete + @return boolean +]=] +function TimedTransitionModel:IsShowingComplete() + return self._transitionModel:IsShowingComplete() +end + +--[=[ + Returns true if hiding is complete + @return boolean +]=] +function TimedTransitionModel:IsHidingComplete() + return self._transitionModel:IsHidingComplete() +end + +--[=[ + Observe is showing is complete + @return Observable +]=] +function TimedTransitionModel:ObserveIsShowingComplete() + return self._transitionModel:ObserveIsShowingComplete() +end + +--[=[ + Observe is hiding is complete + @return Observable +]=] +function TimedTransitionModel:ObserveIsHidingComplete() + return self._transitionModel:ObserveIsHidingComplete() +end + +--[=[ + Binds the transition model to the actual visiblity of the pane + + @param pane BasicPane + @return function -- Cleanup function +]=] +function TimedTransitionModel:BindToPaneVisbility(pane) + local maid = Maid.new() + + maid:GiveTask(pane.VisibleChanged:Connect(function(isVisible, doNotAnimate) + self:SetVisible(isVisible, doNotAnimate) + end)) + maid:GiveTask(self.VisibleChanged:Connect(function(isVisible, doNotAnimate) + pane:SetVisible(isVisible, doNotAnimate) + end)) + + self:SetVisible(pane:IsVisible()) + + self._maid._visibleBinding = maid + + return function() + if not self.Destroy then + return + end + + if self._maid._visibleBinding == maid then + self._maid._visibleBinding = nil + end + end +end + +--[=[ + Observes the spring animating + @return Observable +]=] +function TimedTransitionModel:ObserveRenderStepped() + return self._timedTween:ObserveRenderStepped() +end + +--[=[ + Alias to spring transition model observation! + + @return Observable +]=] +function TimedTransitionModel:Observe() + return self._timedTween:Observe() +end + +--[=[ + Shows the model and promises when the showing is complete. + + @param doNotAnimate boolean + @return Promise +]=] +function TimedTransitionModel:PromiseShow(doNotAnimate) + return self._transitionModel:PromiseShow(doNotAnimate) +end + +--[=[ + Hides the model and promises when the showing is complete. + + @param doNotAnimate boolean + @return Promise +]=] +function TimedTransitionModel:PromiseHide(doNotAnimate) + return self._transitionModel:PromiseHide(doNotAnimate) +end + +--[=[ + Toggles the model and promises when the transition is complete. + + @param doNotAnimate boolean + @return Promise +]=] +function TimedTransitionModel:PromiseToggle(doNotAnimate) + return self._transitionModel:PromiseToggle(doNotAnimate) +end + +function TimedTransitionModel:_promiseShow(maid, doNotAnimate) + self._timedTween:Show(doNotAnimate) + + if doNotAnimate then + return Promise.resolved() + else + return maid:GivePromise(self._timedTween:PromiseFinished()) + end +end + +function TimedTransitionModel:_promiseHide(maid, doNotAnimate) + self._timedTween:Hide(doNotAnimate) + + if doNotAnimate then + return Promise.resolved() + else + return maid:GivePromise(self._timedTween:PromiseFinished()) + end +end + + +return TimedTransitionModel \ No newline at end of file diff --git a/src/userserviceutils/package.json b/src/userserviceutils/package.json index db9c4e1189..bed5472f0f 100644 --- a/src/userserviceutils/package.json +++ b/src/userserviceutils/package.json @@ -30,6 +30,7 @@ "@quenty/maid": "file:../maid", "@quenty/math": "file:../math", "@quenty/promise": "file:../promise", + "@quenty/rx": "file:../rx", "@quenty/servicebag": "file:../servicebag" }, "publishConfig": { diff --git a/src/userserviceutils/src/Client/UserInfoServiceClient.lua b/src/userserviceutils/src/Client/UserInfoServiceClient.lua index 9cad70ef4d..f45f529319 100644 --- a/src/userserviceutils/src/Client/UserInfoServiceClient.lua +++ b/src/userserviceutils/src/Client/UserInfoServiceClient.lua @@ -46,6 +46,18 @@ function UserInfoServiceClient:PromiseDisplayName(userId) return self._aggregator:PromiseDisplayName(userId) end +--[=[ + Observes the user display name for the userId + + @param userId number + @return Observable +]=] +function UserInfoServiceClient:ObserveDisplayName(userId) + assert(type(userId) == "number", "Bad userId") + + return self._aggregator:ObserveDisplayName(userId) +end + function UserInfoServiceClient:Destroy() self._maid:DoCleaning() end diff --git a/src/userserviceutils/src/Server/UserInfoService.lua b/src/userserviceutils/src/Server/UserInfoService.lua index cb24003d3f..06d5bd86d1 100644 --- a/src/userserviceutils/src/Server/UserInfoService.lua +++ b/src/userserviceutils/src/Server/UserInfoService.lua @@ -46,6 +46,19 @@ function UserInfoService:PromiseDisplayName(userId) return self._aggregator:PromiseDisplayName(userId) end +--[=[ + Observes the user display name for the userId + + @param userId number + @return Observable +]=] +function UserInfoService:ObserveDisplayName(userId) + assert(type(userId) == "number", "Bad userId") + + return self._aggregator:ObserveDisplayName(userId) +end + + function UserInfoService:Destroy() self._maid:DoCleaning() end diff --git a/src/userserviceutils/src/Shared/UserInfoAggregator.lua b/src/userserviceutils/src/Shared/UserInfoAggregator.lua index 168b1060d2..4f03e846eb 100644 --- a/src/userserviceutils/src/Shared/UserInfoAggregator.lua +++ b/src/userserviceutils/src/Shared/UserInfoAggregator.lua @@ -9,6 +9,7 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") local Promise = require("Promise") local UserServiceUtils = require("UserServiceUtils") +local Rx = require("Rx") local UserInfoAggregator = setmetatable({}, BaseObject) UserInfoAggregator.ClassName = "UserInfoAggregator" @@ -63,6 +64,18 @@ function UserInfoAggregator:PromiseDisplayName(userId) end) end +--[=[ + Observes the user display name for the userId + + @param userId number + @return Observable +]=] +function UserInfoAggregator:ObserveDisplayName(userId) + assert(type(userId) == "number", "Bad userId") + + return Rx.fromPromise(self:PromiseUserInfo(userId)) +end + function UserInfoAggregator:_sendAggregatedPromises() local promiseMap = self._unsentPromises self._unsentPromises = {} diff --git a/tools/nevermore-cli/src/commands/init-game-command.ts b/tools/nevermore-cli/src/commands/init-game-command.ts index fa2b82c77f..22b29d3a13 100644 --- a/tools/nevermore-cli/src/commands/init-game-command.ts +++ b/tools/nevermore-cli/src/commands/init-game-command.ts @@ -22,7 +22,7 @@ export interface InitGameArgs extends NevermoreGlobalArgs { export class InitGameCommand implements CommandModule { public command = 'init [game-name]'; public describe = - 'Initializes a new game to use Nevermore with Cmdr and a few other packages.'; + 'Initializes a new game template.'; public builder(args: Argv) { args.positional('game-name', { diff --git a/tools/nevermore-cli/src/commands/init-package-command.ts b/tools/nevermore-cli/src/commands/init-package-command.ts index 95e4fadb6d..1aa6cd6572 100644 --- a/tools/nevermore-cli/src/commands/init-package-command.ts +++ b/tools/nevermore-cli/src/commands/init-package-command.ts @@ -12,6 +12,7 @@ import { getTemplatePathByName } from '../utils/nevermore-cli-utils'; export interface InitPackageArgs extends NevermoreGlobalArgs { packageName: string; description: string; + packageTemplate: 'library' | 'service'; } /** @@ -20,25 +21,33 @@ export interface InitPackageArgs extends NevermoreGlobalArgs { export class InitPackageCommand implements CommandModule { - public command = 'init-package [package-name] [description]'; + public command = + 'init-package [package-name] [description] [package-template]'; public describe = 'Initializes a new package within Nevermore.'; public builder(args: Argv) { - args.positional('package-name', { - describe: 'Name of the new package folder.', - demandOption: true, - type: 'string', - }); - args.positional('description', { - describe: 'The description of the package.', - demandOption: true, - type: 'string', - }); - return args as Argv; + let result = args + .positional('package-name', { + describe: 'Name of the new package folder.', + demandOption: true, + type: 'string', + }) + .positional('description', { + describe: 'The description of the package.', + demandOption: true, + type: 'string', + }) + .positional('package-template', { + describe: 'The template type to use.', + default: 'library', + choices: ['library', 'service'], + }); + + return result as any; } public async handler(args: InitPackageArgs) { - const rawPackageName = await InitPackageCommand._ensurePackageName(args); + let rawPackageName = await InitPackageCommand._ensurePackageName(args); const packageName = TemplateHelper.camelize(rawPackageName).toLowerCase(); const packageNameProper = TemplateHelper.camelize(rawPackageName); @@ -46,7 +55,7 @@ export class InitPackageCommand const srcRoot = process.cwd(); const templatePath = getTemplatePathByName( - 'nevermore-library-package-template' + `nevermore-${args.packageTemplate}-package-template` ); OutputHelper.info( diff --git a/tools/nevermore-cli/templates/game-template/aftman.toml b/tools/nevermore-cli/templates/game-template/aftman.toml index f00848fc2a..755cd50696 100644 --- a/tools/nevermore-cli/templates/game-template/aftman.toml +++ b/tools/nevermore-cli/templates/game-template/aftman.toml @@ -2,5 +2,5 @@ # For more information, see https://github.com/LPGhatguy/aftman [tools] rojo = "quenty/rojo@7.3.0-quenty-npm-canary.7" -selene = "Kampfkarren/selene@0.23.1" +selene = "Kampfkarren/selene@0.25.0" moonwave-extractor = "UpliftGames/moonwave@1.0.1" \ No newline at end of file diff --git a/tools/nevermore-cli/templates/game-template/default.project.json b/tools/nevermore-cli/templates/game-template/default.project.json index a36a67028c..7e2665a8d7 100644 --- a/tools/nevermore-cli/templates/game-template/default.project.json +++ b/tools/nevermore-cli/templates/game-template/default.project.json @@ -9,7 +9,7 @@ "game": { "$path": "src/modules" }, - "packages": { + "node_modules": { "$path": "node_modules" } }, diff --git a/tools/nevermore-cli/templates/game-template/src/modules/Client/Binders/ENSURE_FOLDER_CREATED b/tools/nevermore-cli/templates/game-template/src/modules/Client/Binders/ENSURE_FOLDER_CREATED new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/nevermore-cli/templates/game-template/src/modules/Client/{{gameNameProper}}BindersClient.lua b/tools/nevermore-cli/templates/game-template/src/modules/Client/{{gameNameProper}}BindersClient.lua deleted file mode 100644 index c4b0d9e8cb..0000000000 --- a/tools/nevermore-cli/templates/game-template/src/modules/Client/{{gameNameProper}}BindersClient.lua +++ /dev/null @@ -1,12 +0,0 @@ ---[=[ - @class {{gameNameProper}}BindersClient -]=] - -local require = require(script.Parent.loader).load(script) - -local BinderProvider = require("BinderProvider") -local Binder = require("Binder") - -return BinderProvider.new(script.Name, function(self, serviceBag) - -end) \ No newline at end of file diff --git a/tools/nevermore-cli/templates/game-template/src/modules/Client/{{gameNameProper}}ServiceClient.lua b/tools/nevermore-cli/templates/game-template/src/modules/Client/{{gameNameProper}}ServiceClient.lua index 54d18ca6f9..9545fc7b50 100644 --- a/tools/nevermore-cli/templates/game-template/src/modules/Client/{{gameNameProper}}ServiceClient.lua +++ b/tools/nevermore-cli/templates/game-template/src/modules/Client/{{gameNameProper}}ServiceClient.lua @@ -15,7 +15,6 @@ function {{gameNameProper}}ServiceClient:Init(serviceBag) self._serviceBag:GetService(require("CmdrServiceClient")) -- Internal - self._serviceBag:GetService(require("{{gameNameProper}}BindersClient")) self._serviceBag:GetService(require("{{gameNameProper}}Translator")) end diff --git a/tools/nevermore-cli/templates/game-template/src/modules/Server/Binders/ENSURE_FOLDER_CREATED b/tools/nevermore-cli/templates/game-template/src/modules/Server/Binders/ENSURE_FOLDER_CREATED new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/nevermore-cli/templates/game-template/src/modules/Server/{{gameNameProper}}BindersServer.lua b/tools/nevermore-cli/templates/game-template/src/modules/Server/{{gameNameProper}}BindersServer.lua deleted file mode 100644 index c4b0d9e8cb..0000000000 --- a/tools/nevermore-cli/templates/game-template/src/modules/Server/{{gameNameProper}}BindersServer.lua +++ /dev/null @@ -1,12 +0,0 @@ ---[=[ - @class {{gameNameProper}}BindersClient -]=] - -local require = require(script.Parent.loader).load(script) - -local BinderProvider = require("BinderProvider") -local Binder = require("Binder") - -return BinderProvider.new(script.Name, function(self, serviceBag) - -end) \ No newline at end of file diff --git a/tools/nevermore-cli/templates/game-template/src/modules/Server/{{gameNameProper}}Service.lua b/tools/nevermore-cli/templates/game-template/src/modules/Server/{{gameNameProper}}Service.lua index 02f434b521..e062dbcf88 100644 --- a/tools/nevermore-cli/templates/game-template/src/modules/Server/{{gameNameProper}}Service.lua +++ b/tools/nevermore-cli/templates/game-template/src/modules/Server/{{gameNameProper}}Service.lua @@ -15,7 +15,6 @@ function {{gameNameProper}}Service:Init(serviceBag) self._serviceBag:GetService(require("CmdrService")) -- Internal - self._serviceBag:GetService(require("{{gameNameProper}}BindersServer")) end return {{gameNameProper}}Service \ No newline at end of file diff --git a/tools/nevermore-cli/templates/nevermore-library-package-template/src/Client/ENSURE_FOLDER_CREATED b/tools/nevermore-cli/templates/nevermore-library-package-template/src/Client/ENSURE_FOLDER_CREATED new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/nevermore-cli/templates/nevermore-library-package-template/src/Server/ENSURE_FOLDER_CREATED b/tools/nevermore-cli/templates/nevermore-library-package-template/src/Server/ENSURE_FOLDER_CREATED new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/README.md b/tools/nevermore-cli/templates/nevermore-service-package-template/README.md new file mode 100644 index 0000000000..4b3a3c2d0c --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/README.md @@ -0,0 +1,23 @@ +## {{packageNameProper}} + + + +{{description}} + + + +## Installation + +``` +npm install @quenty/{{packageName}} --save +``` diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/default.project.json b/tools/nevermore-cli/templates/nevermore-service-package-template/default.project.json new file mode 100644 index 0000000000..4c3f0e4889 --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "{{packageName}}", + "tree": { + "$path": "src" + } +} diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/package.json b/tools/nevermore-cli/templates/nevermore-service-package-template/package.json new file mode 100644 index 0000000000..9fb6a703dc --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/package.json @@ -0,0 +1,33 @@ +{ + "name": "@quenty/{{packageName}}", + "version": "1.0.0", + "description": "{{description}}", + "keywords": [ + "Roblox", + "Nevermore", + "Lua", + "{{packageName}}" + ], + "bugs": { + "url": "https://github.com/Quenty/NevermoreEngine/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Quenty/NevermoreEngine.git", + "directory": "src/{{packageName}}/" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/quenty" + }, + "license": "MIT", + "contributors": [ + "Quenty" + ], + "dependencies": { + "@quenty/loader": "file:../loader" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/src/Client/{{packageNameProper}}ServiceClient.lua b/tools/nevermore-cli/templates/nevermore-service-package-template/src/Client/{{packageNameProper}}ServiceClient.lua new file mode 100644 index 0000000000..94a7c1698d --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/src/Client/{{packageNameProper}}ServiceClient.lua @@ -0,0 +1,17 @@ +--[=[ + @class {{packageNameProper}}ServiceClient +]=] + +local require = require(script.Parent.loader).load(script) + +local {{packageNameProper}}ServiceClient = {} +{{packageNameProper}}ServiceClient.ServiceName = "{{packageNameProper}}ServiceClient" + +function {{packageNameProper}}ServiceClient:Init(serviceBag) + assert(not self._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + + +end + +return {{packageNameProper}}ServiceClient \ No newline at end of file diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/src/Server/{{packageNameProper}}Service.lua b/tools/nevermore-cli/templates/nevermore-service-package-template/src/Server/{{packageNameProper}}Service.lua new file mode 100644 index 0000000000..1291db2397 --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/src/Server/{{packageNameProper}}Service.lua @@ -0,0 +1,17 @@ +--[=[ + @class {{packageNameProper}}Service +]=] + +local require = require(script.Parent.loader).load(script) + +local {{packageNameProper}}Service = {} +{{packageNameProper}}Service.ServiceName = "{{packageNameProper}}Service" + +function {{packageNameProper}}Service:Init(serviceBag) + assert(not self._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + + +end + +return {{packageNameProper}}Service \ No newline at end of file diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/src/Shared/ENSURE_FOLDER_CREATED b/tools/nevermore-cli/templates/nevermore-service-package-template/src/Shared/ENSURE_FOLDER_CREATED new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/src/node_modules.project.json b/tools/nevermore-cli/templates/nevermore-service-package-template/src/node_modules.project.json new file mode 100644 index 0000000000..46233dac4f --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/test/default.project.json b/tools/nevermore-cli/templates/nevermore-service-package-template/test/default.project.json new file mode 100644 index 0000000000..b137d5e6a8 --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/test/default.project.json @@ -0,0 +1,21 @@ +{ + "name": "{{packageNameProper}}Test", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "{{packageName}}": { + "$path": ".." + }, + "Script": { + "$path": "scripts/Server" + } + }, + "StarterPlayer": { + "StarterPlayerScripts": { + "Main": { + "$path": "scripts/Client" + } + } + } + } +} \ No newline at end of file diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Client/ClientMain.client.lua b/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Client/ClientMain.client.lua new file mode 100644 index 0000000000..15b6fbf157 --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Client/ClientMain.client.lua @@ -0,0 +1,11 @@ +--[[ + @class ClientMain +]] +local packages = game:GetService("ReplicatedStorage"):WaitForChild("Packages") + +local serviceBag = require(packages.ServiceBag).new() +serviceBag:GetService(packages.{{packageNameProper}}ServiceClient) + +-- Start game +serviceBag:Init() +serviceBag:Start() \ No newline at end of file diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua b/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 0000000000..ddb06a545d --- /dev/null +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,14 @@ +--[[ + @class ServerMain +]] +local ServerScriptService = game:GetService("ServerScriptService") + +local loader = ServerScriptService:FindFirstChild("LoaderUtils", true).Parent +local packages = require(loader).bootstrapGame(ServerScriptService.{{packageName}}) + +local serviceBag = require(packages.ServiceBag).new() +serviceBag:GetService(packages.{{packageNameProper}}Service) + +-- Start game +serviceBag:Init() +serviceBag:Start() \ No newline at end of file