Step 12 of 14, updated

Owns and invariants

owns gives a field to its system, so a write from elsewhere is an error. invariant states a rule that shot run checks after every frame.

You'll learn owned fields, rules that always hold, and how a broken rule reads.

A field one system owns

Wallet.Balance should only change through Wallet:Add, which also keeps the change in the history. Today that is a comment, and a write from elsewhere quietly skips the history. One line in the system's Types.shot gives the field to the system:

struck ShotScript writes it for youhighlighted new in ShotScript
Luau todayWallet (a ModuleScript, part)
-- Change Balance only through-- Wallet:Add: Add also keeps the-- change in the history.function Wallet.Add(self: Wallet,	amount: number, reason: Types.Reason)
ShotScriptWallet/Types.shot (part)
doc {	Add also keeps the change in the	history; a write to Balance does not}owns Wallet.Balance via Wallet:Add
  • owns Class.Field via Module.Function names a class or module of this folder, its field, and the function that changes it (Class:Method for a method). One line per field.
  • The doc above it is the why: the error quotes it.
  • It compiles to nothing; it only adds a check, on =, +=, -= and the rest.

Write it from Stats, with a function that empties a wallet (and Wallet added to the import in Stats/Types.shot):

ShotScriptStats/Stats.shot (part)
doc { Empties a wallet }
function Reset(wallet : Wallet)
	wallet.Balance = 0
end
$ shot check
./Stats/Stats.shot:13:10   Wallet.Balance belongs to Wallet: change it with Wallet:Add instead (Add also keeps the change in the history; a write to Balance does not)
1 errors in 1 file (16 files checked, 3.7 s)

Watch out A write is matched by the object's type: a typed parameter or local, self, module, a use name. An untyped local, a loop variable or a map element (Bank.Wallets[player].Balance = 0) is not followed.

A rule that always holds

No wallet should hold less than 0 coins. Instead of an assert after the writes you remember, state the rule once, in words and as a function:

Luau todayShopServer (a ModuleScript, part)
function ShopServer.Buy(player, item)	-- ...	wallet:Add(-price, "Shop")	assert(wallet.Balance >= 0)end
ShotScriptWallet/Types.shot (part)
doc {	No wallet holds less than 0 coins: the	shop sells only what the player can pay}invariant Bank.NoneBelowZero

The function is the module's own, and the game never calls it:

ShotScriptWallet/Bank.shot (part)
doc { Rule: no wallet is below 0 }
function NoneBelowZero() : (boolean, string?)
	for player, wallet in module.Wallets do
		if wallet.Balance < 0 then
			return false, player.Name
				.. " has " .. wallet.Balance
		end
	end
	return true, nil
end
  • invariant Module.Function goes in a Types.shot, with its doc above it: the rule in words, printed when it breaks.
  • The module is a server or shared module of the system's own folder, so rules are checked on the server.
  • The function takes nothing and returns boolean or (boolean, string?): false and a short text with the values that broke it.
  • It only reads: shot check refuses a rule that writes a field, fires a signal or waits. shot build compiles nothing for the line.

Break it

A player with 5 coins asks for a Sword priced at 10:

LuauScenarios/Broke.luau
-- Broke: a player with 5 coins asks for a Sword priced at 10.
return function(world)
	local Bank = world.Server.Require("Wallet.Bank")
	world:Boot()
	local ana, client = world:AddPlayer("Ana")
	world:Step(0.1)
	Bank.Give(ana, 5)
	client.Require("Wallet.Shop").Client.Buy("Sword")
	world:Step(0.1)
	world.Logf("Ana has %s", Bank.Wallets[ana]:Describe())
end
$ shot run Broke
==== Broke
[client:Ana print] client started
Ana has 5 coins

==== summary
  Broke          OK  (2 lines, 0 errors, 0.2s virtual)
1 of 1 scenario(s) passed

Now someone drops the if wallet.Balance >= price then line and its end from ShopServer.Buy. The types are fine, shot check says 0 errors, and the scenario has no assert. The rule catches it:

$ shot run Broke
==== Broke
[client:Ana print] client started
Ana has -5 coins
---- 1 invariant(s) broken: a rule declared in a Types.shot failed between two frames (each once, at its first frame)
  invariant broken: No wallet holds less than 0 coins: the shop sells only what the player can pay  (Bank.NoneBelowZero, Wallet/Types.shot:26)
    first at frame 7 (0.12s virtual), after log line 1, scenario Scenarios/Broke.luau:9: Ana has -5

==== summary
  Broke          FAILED  (2 lines, 0 errors, 1 invariant(s) broken, 0.2s virtual)
0 of 1 scenario(s) passed

Tip The scenario line is the world:Step that ran the frame; the change that broke the rule ran after the log line it names. Fix the code that broke the rule, never the rule, unless it is no longer true.

Try it

Put module.Count = 0 at the top of NoneBelowZero and run shot check: the rule writes module.Count, and a rule only reads, because a rule that changes the game would change what it checks.