This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,31 @@
local Workspace = game:GetService("Workspace")
local Thunk = require(script.Parent.Parent.Thunk)
local SetPromptState = require(script.Parent.Parent.Actions.SetPromptState)
local PromptState = require(script.Parent.Parent.PromptState)
--[[
This delay is used to make sure the animation plays long enough
for the player to see that the purchase is happening; it's only
for visual effect
]]
local DELAY = 1
local function completePurchase()
return Thunk.new(script.Name, {}, function(store, services)
local startTime = store:getState().purchasingStartTime
local timeElapsed = startTime - Workspace.DistributedGameTime
if timeElapsed >= DELAY then
return store:dispatch(SetPromptState(PromptState.PurchaseComplete))
else
delay(DELAY - timeElapsed, function()
return store:dispatch(SetPromptState(PromptState.PurchaseComplete))
end)
end
end)
end
return completePurchase
@@ -0,0 +1,71 @@
local Players = game:GetService("Players")
local Promise = require(script.Parent.Parent.Promise)
local Thunk = require(script.Parent.Parent.Thunk)
local Network = require(script.Parent.Parent.Services.Network)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local PurchaseError = require(script.Parent.Parent.PurchaseError)
local ErrorOccurred = require(script.Parent.Parent.Actions.ErrorOccurred)
local SetProduct = require(script.Parent.Parent.Actions.SetProduct)
local getProductInfo = require(script.Parent.Parent.Network.getProductInfo)
local getIsAlreadyOwned = require(script.Parent.Parent.Network.getIsAlreadyOwned)
local getAccountInfo = require(script.Parent.Parent.Network.getAccountInfo)
local resolvePromptState = require(script.Parent.resolvePromptState)
local requiredServices = {
Network,
ExternalSettings,
}
local function initiatePurchase(id, infoType, equipIfPurchased)
return Thunk.new(script.Name, requiredServices, function(store, services)
local network = services[Network]
local externalSettings = services[ExternalSettings]
--[[
If a purchase is already in progress, we abort the new one
]]
if store:getState().product.id ~= nil then
return nil
end
store:dispatch(SetProduct(id, infoType, equipIfPurchased))
local isStudio = externalSettings.isStudio()
if not isStudio and Players.LocalPlayer.UserId <= 0 then
store:dispatch(ErrorOccurred(PurchaseError.Guest))
return nil
end
if externalSettings.getFlagOrder66() then
store:dispatch(ErrorOccurred(PurchaseError.PurchaseDisabled))
return nil
end
return Promise.all({
productInfo = getProductInfo(network, id, infoType),
accountInfo = getAccountInfo(network, externalSettings),
alreadyOwned = getIsAlreadyOwned(network, id, infoType),
})
:andThen(function(results)
-- Once we've finished all of our async data fetching, we'll
-- resolve the state of the prompt
store:dispatch(resolvePromptState(
results.productInfo,
results.accountInfo,
results.alreadyOwned
))
end)
:catch(function(errorReason)
store:dispatch(ErrorOccurred(errorReason))
end)
end)
end
return initiatePurchase
@@ -0,0 +1,68 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local Reducer = require(script.Parent.Parent.Reducers.Reducer)
local MockNetwork = require(script.Parent.Parent.Test.MockNetwork)
local MockExternalSettings = require(script.Parent.Parent.Test.MockExternalSettings)
local Network = require(script.Parent.Parent.Services.Network)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local PromptState = require(script.Parent.Parent.PromptState)
local Thunk = require(script.Parent.Parent.Thunk)
local initiatePurchase = require(script.Parent.initiatePurchase)
it("should run without errors", function()
local store = Rodux.Store.new(Reducer)
local thunk = initiatePurchase(15, Enum.InfoType.Product, false)
Thunk.test(thunk, store, {
[Network] = MockNetwork.new(),
[ExternalSettings] = MockExternalSettings.new(false, false, false, false),
})
local state = store:getState()
expect(state.product.id).to.equal(15)
end)
it("should abort when a purchase is already in progress", function()
local store = Rodux.Store.new(Reducer, {
promptState = PromptState.PromptPurchase,
product = {
id = 12,
infoType = Enum.InfoType.Product,
}
})
-- Initiate a purchase for a different product id
local thunk = initiatePurchase(999, Enum.InfoType.Product, false)
Thunk.test(thunk, store, {
[Network] = MockNetwork.new(),
[ExternalSettings] = MockExternalSettings.new(false, false, false, false),
})
local state = store:getState()
expect(state.product.id).to.equal(12)
expect(state.promptState).to.equal(PromptState.PromptPurchase)
end)
it("should resolve to an error state if a network failure occurs", function()
local store = Rodux.Store.new(Reducer)
local thunk = initiatePurchase(15, Enum.InfoType.Product, false)
Thunk.test(thunk, store, {
[Network] = MockNetwork.new(true),
[ExternalSettings] = MockExternalSettings.new(false, false, false, false),
})
local state = store:getState()
expect(state.promptState).to.equal(PromptState.Error)
end)
end
@@ -0,0 +1,28 @@
local Thunk = require(script.Parent.Parent.Thunk)
local PlatformInterface = require(script.Parent.Parent.Services.PlatformInterface)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local PromptState = require(script.Parent.Parent.PromptState)
local SetPromptState = require(script.Parent.Parent.Actions.SetPromptState)
local requiredServices = {
ExternalSettings,
PlatformInterface,
}
local function launchBuildersClubUpsell()
return Thunk.new(script.Name, requiredServices, function(store, services)
local externalSettings = services[ExternalSettings]
local platformInterface = services[PlatformInterface]
if externalSettings.isStudio() then
return
end
platformInterface.startBuildersClubUpsellWeb()
store:dispatch(SetPromptState(PromptState.UpsellInProgress))
end)
end
return launchBuildersClubUpsell
@@ -0,0 +1,35 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local Reducer = require(script.Parent.Parent.Reducers.Reducer)
local MockPlatformInterface = require(script.Parent.Parent.Test.MockPlatformInterface)
local MockExternalSettings = require(script.Parent.Parent.Test.MockExternalSettings)
local PlatformInterface = require(script.Parent.Parent.Services.PlatformInterface)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local PromptState = require(script.Parent.Parent.PromptState)
local Thunk = require(script.Parent.Parent.Thunk)
local launchBuildersClubUpsell = require(script.Parent.launchBuildersClubUpsell)
it("should run without errors", function()
local store = Rodux.Store.new(Reducer)
local thunk = launchBuildersClubUpsell()
local externalSettings = MockExternalSettings.new(false, false, false, false)
local platformInterface = MockPlatformInterface.new()
Thunk.test(thunk, store, {
[ExternalSettings] = externalSettings,
[PlatformInterface] = platformInterface,
})
local state = store:getState()
expect(platformInterface.startBuildersClubUpsellWeb_callCount).to.equal(1)
expect(state.promptState).to.equal(PromptState.UpsellInProgress)
end)
end
@@ -0,0 +1,60 @@
local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local Thunk = require(script.Parent.Parent.Thunk)
local Promise = require(script.Parent.Parent.Promise)
local UpsellFlow = require(script.Parent.Parent.UpsellFlow)
local PromptState = require(script.Parent.Parent.PromptState)
local SetPromptState = require(script.Parent.Parent.Actions.SetPromptState)
local Analytics = require(script.Parent.Parent.Services.Analytics)
local PlatformInterface = require(script.Parent.Parent.Services.PlatformInterface)
local getUpsellFlow = require(script.Parent.Parent.NativeUpsell.getUpsellFlow)
local retryAfterUpsell = require(script.Parent.retryAfterUpsell)
local requiredServices = {
Analytics,
PlatformInterface,
}
local function launchRobuxUpsell()
return Thunk.new(script.Name, requiredServices, function(store, services)
local analytics = services[Analytics]
local platformInterface = services[PlatformInterface]
local upsellFlow = getUpsellFlow(UserInputService:GetPlatform())
if upsellFlow == UpsellFlow.Web then
platformInterface.startRobuxUpsellWeb()
analytics.reportRobuxUpsellStarted()
store:dispatch(SetPromptState(PromptState.UpsellInProgress))
elseif upsellFlow == UpsellFlow.Mobile then
local nativeProductId = store:getState().nativeUpsell.robuxProductId
platformInterface.promptNativePurchase(Players.LocalPlayer, nativeProductId)
store:dispatch(SetPromptState(PromptState.UpsellInProgress))
elseif upsellFlow == UpsellFlow.Xbox then
local nativeProductId = store:getState().nativeUpsell.robuxProductId
store:dispatch(SetPromptState(PromptState.UpsellInProgress))
return Promise.new(function(resolve, reject)
local platformPurchaseResult = platformInterface.beginPlatformStorePurchase(nativeProductId)
Promise.resolve(platformPurchaseResult)
end)
:andThen(function(result)
if result ~= 0 then
store:dispatch(retryAfterUpsell)
end
end)
else
warn("Need more Robux: platform not supported for Robux purchase")
end
end)
end
return launchRobuxUpsell
@@ -0,0 +1,36 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local Reducer = require(script.Parent.Parent.Reducers.Reducer)
local MockAnalytics = require(script.Parent.Parent.Test.MockAnalytics)
local MockPlatformInterface = require(script.Parent.Parent.Test.MockPlatformInterface)
local Analytics = require(script.Parent.Parent.Services.Analytics)
local PlatformInterface = require(script.Parent.Parent.Services.PlatformInterface)
local PromptState = require(script.Parent.Parent.PromptState)
local Thunk = require(script.Parent.Parent.Thunk)
local launchRobuxUpsell = require(script.Parent.launchRobuxUpsell)
it("should run without errors", function()
local store = Rodux.Store.new(Reducer)
local thunk = launchRobuxUpsell()
local analytics = MockAnalytics.new()
local platformInterface = MockPlatformInterface.new()
Thunk.test(thunk, store, {
[Analytics] = analytics,
[PlatformInterface] = platformInterface,
})
local state = store:getState()
expect(analytics.reportRobuxUpsellStarted_callCount).to.equal(1)
expect(platformInterface.startRobuxUpsellWeb_callCount).to.equal(1)
expect(state.promptState).to.equal(PromptState.UpsellInProgress)
end)
end
@@ -0,0 +1,74 @@
local HttpService = game:GetService("HttpService")
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")
local Thunk = require(script.Parent.Parent.Thunk)
local Promise = require(script.Parent.Parent.Promise)
local Network = require(script.Parent.Parent.Services.Network)
local Analytics = require(script.Parent.Parent.Services.Analytics)
local StartPurchase = require(script.Parent.Parent.Actions.StartPurchase)
local ErrorOccurred = require(script.Parent.Parent.Actions.ErrorOccurred)
local getToolAsset = require(script.Parent.Parent.Network.getToolAsset)
local completePurchase = require(script.Parent.completePurchase)
local performPurchase = require(script.Parent.Parent.Network.performPurchase)
-- Only tools can be equipped on purchase
local ASSET_TYPE_TOOL = 19
local requiredServices = {
Network,
Analytics,
}
local function purchaseItem()
return Thunk.new(script.Name, requiredServices, function(store, services)
local network = services[Network]
local analytics = services[Analytics]
store:dispatch(StartPurchase(Workspace.DistributedGameTime))
local state = store:getState()
local requestId = HttpService:GenerateGUID(false)
local id = state.product.id
local infoType = state.product.infoType
local equipIfPurchased = state.product.equipIfPurchased
local salePrice = state.productInfo.price
local assetTypeId = state.productInfo.assetTypeId
local productId = state.productInfo.productId
return performPurchase(network, infoType, productId, salePrice, requestId)
:andThen(function(result)
--[[
If the purchase was successful, we signal success,
record analytics, and equip the item if needed
]]
store:dispatch(completePurchase())
analytics.signalPurchaseSuccess(id, infoType, salePrice, result)
if equipIfPurchased and assetTypeId == ASSET_TYPE_TOOL then
return getToolAsset(network, id)
:andThen(function(tool)
if tool then
tool.Parent = Players.LocalPlayer.Backpack
end
end)
end
return Promise.resolve()
end)
:catch(function(errorReason)
store:dispatch(ErrorOccurred(errorReason))
end)
end)
end
return purchaseItem
@@ -0,0 +1,53 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local Reducer = require(script.Parent.Parent.Reducers.Reducer)
local MockNetwork = require(script.Parent.Parent.Test.MockNetwork)
local MockAnalytics = require(script.Parent.Parent.Test.MockAnalytics)
local Network = require(script.Parent.Parent.Services.Network)
local Analytics = require(script.Parent.Parent.Services.Analytics)
local PromptState = require(script.Parent.Parent.PromptState)
local Thunk = require(script.Parent.Parent.Thunk)
local purchaseItem = require(script.Parent.purchaseItem)
it("should run without errors", function()
local store = Rodux.Store.new(Reducer)
local thunk = purchaseItem()
local network = MockNetwork.new()
local analytics = MockAnalytics.new()
Thunk.test(thunk, store, {
[Network] = network,
[Analytics] = analytics,
})
local state = store:getState()
expect(analytics.signalPurchaseSuccess_callCount).to.equal(1)
expect(state.promptState).to.equal(PromptState.PurchaseInProgress)
end)
it("should resolve to an error state if a network error occurs", function()
local store = Rodux.Store.new(Reducer)
local thunk = purchaseItem()
local network = MockNetwork.new(true)
local analytics = MockAnalytics.new()
Thunk.test(thunk, store, {
[Network] = network,
[Analytics] = analytics,
})
local state = store:getState()
expect(analytics.signalPurchaseSuccess_callCount).to.equal(0)
expect(state.promptState).to.equal(PromptState.Error)
end)
end
@@ -0,0 +1,78 @@
local UserInputService = game:GetService("UserInputService")
local Thunk = require(script.Parent.Parent.Thunk)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local PromptState = require(script.Parent.Parent.PromptState)
local PurchaseError = require(script.Parent.Parent.PurchaseError)
local UpsellFlow = require(script.Parent.Parent.UpsellFlow)
local SetPromptState = require(script.Parent.Parent.Actions.SetPromptState)
local ProductInfoReceived = require(script.Parent.Parent.Actions.ProductInfoReceived)
local AccountInfoReceived = require(script.Parent.Parent.Actions.AccountInfoReceived)
local ItemCannotBePurchased = require(script.Parent.Parent.Actions.ItemCannotBePurchased)
local PromptNativeUpsell = require(script.Parent.Parent.Actions.PromptNativeUpsell)
local selectRobuxProduct = require(script.Parent.Parent.NativeUpsell.selectRobuxProduct)
local getUpsellFlow = require(script.Parent.Parent.NativeUpsell.getUpsellFlow)
local meetsPrerequisites = require(script.Parent.Parent.meetsPrerequisites)
local requiredServices = {
ExternalSettings,
}
local function resolvePromptState(productInfo, accountInfo, alreadyOwned)
return Thunk.new(script.Name, requiredServices, function(store, services)
local externalSettings = services[ExternalSettings]
store:dispatch(ProductInfoReceived(productInfo))
store:dispatch(AccountInfoReceived(accountInfo))
local restrictThirdParty = externalSettings.getFlagRestrictSales2()
local canPurchase, failureReason = meetsPrerequisites(productInfo, alreadyOwned, restrictThirdParty)
if not canPurchase then
return store:dispatch(ItemCannotBePurchased(failureReason))
end
-- Price may be nil if the item is free
local price = productInfo.PriceInRobux or 0
local platform = UserInputService:GetPlatform()
local upsellFlow = getUpsellFlow(platform)
if productInfo.MinimumMembershipLevel > accountInfo.MembershipType then
if upsellFlow == UpsellFlow.Web then
return store:dispatch(SetPromptState(PromptState.BuildersClubUpsell))
else
return store:dispatch(SetPromptState(PurchaseError.BuildersClubLevelTooLow))
end
elseif price > accountInfo.RobuxBalance then
if upsellFlow == UpsellFlow.Web then
return store:dispatch(SetPromptState(PromptState.RobuxUpsell))
else
local hasBuildersClub = accountInfo.MembershipType > 0
return selectRobuxProduct(platform, price, hasBuildersClub)
:andThen(function(product)
-- We found a valid upsell product for the current platform
store:dispatch(PromptNativeUpsell(product.productId, product.robuxValue))
end, function()
-- No upsell item will provide sufficient funds to make this purchase
if platform == Enum.Platform.XBoxOne then
store:dispatch(ItemCannotBePurchased(PurchaseError.NotEnoughRobuxXbox))
else
store:dispatch(ItemCannotBePurchased(PurchaseError.NotEnoughRobux))
end
end)
end
end
return store:dispatch(SetPromptState(PromptState.PromptPurchase))
end)
end
return resolvePromptState
@@ -0,0 +1,125 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local Reducer = require(script.Parent.Parent.Reducers.Reducer)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local MockExternalSettings = require(script.Parent.Parent.Test.MockExternalSettings)
local PromptState = require(script.Parent.Parent.PromptState)
local Thunk = require(script.Parent.Parent.Thunk)
local resolvePromptState = require(script.Parent.resolvePromptState)
local function getTestProductInfo()
return {
IsForSale = true,
Name = "Test Product",
PriceInRobux = 10,
MinimumMembershipLevel = 0,
}
end
it("should populate store with provided info", function()
local store = Rodux.Store.new(Reducer, {})
local productInfo = getTestProductInfo()
local accountInfo = {
RobuxBalance = 10,
MembershipType = 0,
}
local thunk = resolvePromptState(productInfo, accountInfo, false)
Thunk.test(thunk, store, {
[ExternalSettings] = MockExternalSettings.new(false, false, false, false)
})
local state = store:getState()
expect(state.productInfo.name).to.be.ok()
expect(state.accountInfo.balance).to.be.ok()
end)
it("should resolve state to CannotPurchase if prerequisites are failed", function()
local store = Rodux.Store.new(Reducer, {})
local productInfo = getTestProductInfo()
-- Set product to not for sale
productInfo.IsForSale = false
local accountInfo = {
RobuxBalance = 10,
MembershipType = 0,
}
local thunk = resolvePromptState(productInfo, accountInfo, false)
Thunk.test(thunk, store, {
[ExternalSettings] = MockExternalSettings.new(false, false, false, false)
})
local state = store:getState()
expect(state.promptState).to.equal(PromptState.CannotPurchase)
end)
it("should resolve state to PromptPurchase if account meets requirements", function()
local store = Rodux.Store.new(Reducer, {})
local productInfo = getTestProductInfo()
local accountInfo = {
RobuxBalance = 10,
MembershipType = 0,
}
local thunk = resolvePromptState(productInfo, accountInfo, false)
Thunk.test(thunk, store, {
[ExternalSettings] = MockExternalSettings.new(false, false, false, false)
})
local state = store:getState()
expect(state.promptState).to.equal(PromptState.PromptPurchase)
end)
it("should resolve state to RobuxUpsell if account is short on Robux", function()
local store = Rodux.Store.new(Reducer, {})
local productInfo = getTestProductInfo()
-- Player will not have enough robux
local accountInfo = {
RobuxBalance = 0,
MembershipType = 0,
}
local thunk = resolvePromptState(productInfo, accountInfo, false)
Thunk.test(thunk, store, {
[ExternalSettings] = MockExternalSettings.new(false, false, false, false)
})
local state = store:getState()
expect(state.promptState).to.equal(PromptState.RobuxUpsell)
end)
it("should resolve state to BuildersClubUpsell if account has insufficient bc level", function()
local store = Rodux.Store.new(Reducer, {})
local productInfo = getTestProductInfo()
-- Player does not have the sufficient BC level
productInfo.MinimumMembershipLevel = 3
local accountInfo = {
RobuxBalance = 0,
MembershipType = 0,
}
local thunk = resolvePromptState(productInfo, accountInfo, false)
Thunk.test(thunk, store, {
[ExternalSettings] = MockExternalSettings.new(false, false, false, false)
})
local state = store:getState()
expect(state.promptState).to.equal(PromptState.BuildersClubUpsell)
end)
end
@@ -0,0 +1,75 @@
local Thunk = require(script.Parent.Parent.Thunk)
local PurchaseError = require(script.Parent.Parent.PurchaseError)
local Network = require(script.Parent.Parent.Services.Network)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local AccountInfoReceived = require(script.Parent.Parent.Actions.AccountInfoReceived)
local ErrorOccurred = require(script.Parent.Parent.Actions.ErrorOccurred)
local getAccountInfo = require(script.Parent.Parent.Network.getAccountInfo)
local purchaseItem = require(script.Parent.purchaseItem)
local MAX_RETRIES = 3
local RETRY_RATE = 1
local requiredServices = {
Network,
ExternalSettings,
}
local function retryAfterUpsell(retriesRemaining)
retriesRemaining = retriesRemaining or MAX_RETRIES
return Thunk.new(script.Name, requiredServices, function(store, services)
local network = services[Network]
local externalSettings = services[ExternalSettings]
return getAccountInfo(network, externalSettings)
:andThen(function(accountInfo)
local state = store:getState()
local price = state.productInfo.price
local minimumBCLevel = state.productInfo.bcLevelRequired
local balance = accountInfo.RobuxBalance
local membershipLevel = accountInfo.MembershipType
store:dispatch(AccountInfoReceived(accountInfo))
if price ~= nil and price > balance then
if retriesRemaining > 0 then
-- Upsell result may not yet have propagated, so we need to
-- wait a while and try again
delay(RETRY_RATE, function()
store:dispatch(retryAfterUpsell(retriesRemaining - 1))
end)
else
store:dispatch(ErrorOccurred(PurchaseError.InvalidFunds))
end
elseif minimumBCLevel > membershipLevel then
if retriesRemaining > 0 then
-- Upsell result may not yet have propagated, so we need to
-- wait a while and try again
delay(RETRY_RATE, function()
store:dispatch(retryAfterUpsell(retriesRemaining - 1))
end)
else
store:dispatch(ErrorOccurred(PurchaseError.BuildersClubUpsellFailure))
end
else
-- Upsell was successful and purchase can now be completed
store:dispatch(purchaseItem())
end
end)
:catch(function(error)
store:dispatch(ErrorOccurred(error))
end)
end)
end
return retryAfterUpsell
@@ -0,0 +1,44 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local Reducer = require(script.Parent.Parent.Reducers.Reducer)
local MockNetwork = require(script.Parent.Parent.Test.MockNetwork)
local MockExternalSettings = require(script.Parent.Parent.Test.MockExternalSettings)
local Network = require(script.Parent.Parent.Services.Network)
local ExternalSettings = require(script.Parent.Parent.Services.ExternalSettings)
local Thunk = require(script.Parent.Parent.Thunk)
local retryAfterUpsell = require(script.Parent.retryAfterUpsell)
it("should run without errors", function()
local store = Rodux.Store.new(Reducer, {
productInfo = {
price = 0,
bcLevelRequired = 0,
}
})
local thunk = retryAfterUpsell()
local network = MockNetwork.new()
local externalSettings = MockExternalSettings.new(true, false, false, false)
Thunk.test(thunk, store, {
[Network] = network,
[ExternalSettings] = externalSettings,
})
local state = store:getState()
local accountInfo
network.getAccountInfo():andThen(function(result)
accountInfo = result
end)
-- Account info should be re-populated
expect(state.accountInfo.balance).to.be.equal(accountInfo.RobuxBalance)
expect(state.accountInfo.bcLevel).to.be.equal(accountInfo.MembershipType)
end)
end