From 3b46169078bd3495028f09ce1e6ae305a9e76364 Mon Sep 17 00:00:00 2001 From: Ashok Menon Date: Fri, 24 May 2024 19:03:12 +0100 Subject: [PATCH] [Examples/Turnip Town] Rewrite to leverage Kiosk Apps This is an almost complete rewrite of the demo. Now, instead of demonstrating shared/permissioned access to a game between the game admin/service and the player, we are demonstrating a game that allows multiple players to interact with each other's resources. Additionally, instead of a "weather" dynamic, crops are watered by individuals, fetching water from a well that outputs a fixed quantity of water every epoch (it does not accumulate). The parameters of the game are now as follows: - Turnips need as much water as their size to stay fresh every day. - They can grow an additional 20 size units a day if given enough water (1 unit of water = 1 unit of growth). - Each player gets access to 100 units of water per day, via their well. Water must be used in the transaction it was fetched from the well -- it cannot be stockpiled. - If there is more than 100 units of additional water left, it is stagnant and the turnip will also lose freshness (it will rot). - Turnips need to grow to at least 50 units before they can be harvested. - Each player owns a field and a well. Only they can plant turnips on their field, and take water from their well. - Any player can water crops in anybody else's field, and any player can harvest crops as well, but harvested crops go to the field owner's Kiosk. New tests have been added for all these features -- test coverage for this package is 99.14%. --- examples/turnip-town/move/Move.toml | 3 +- examples/turnip-town/move/sources/admin.move | 42 -- examples/turnip-town/move/sources/field.move | 662 +++++------------- examples/turnip-town/move/sources/game.move | 237 +++---- .../move/sources/royalty_policy.move | 60 ++ examples/turnip-town/move/sources/turnip.move | 144 ++-- examples/turnip-town/move/sources/water.move | 91 +++ .../turnip-town/move/tests/field_tests.move | 287 ++++++++ .../turnip-town/move/tests/game_tests.move | 161 +++++ .../move/tests/royalty_policy_tests.move | 86 +++ .../turnip-town/move/tests/turnip_tests.move | 85 +++ .../turnip-town/move/tests/water_tests.move | 76 ++ 12 files changed, 1212 insertions(+), 722 deletions(-) delete mode 100644 examples/turnip-town/move/sources/admin.move create mode 100644 examples/turnip-town/move/sources/royalty_policy.move create mode 100644 examples/turnip-town/move/sources/water.move create mode 100644 examples/turnip-town/move/tests/field_tests.move create mode 100644 examples/turnip-town/move/tests/game_tests.move create mode 100644 examples/turnip-town/move/tests/royalty_policy_tests.move create mode 100644 examples/turnip-town/move/tests/turnip_tests.move create mode 100644 examples/turnip-town/move/tests/water_tests.move diff --git a/examples/turnip-town/move/Move.toml b/examples/turnip-town/move/Move.toml index 3972534aae7ce..ac648195f8a46 100644 --- a/examples/turnip-town/move/Move.toml +++ b/examples/turnip-town/move/Move.toml @@ -1,9 +1,8 @@ [package] name = "turnip-town" -version = "0.0.1" +edition = "2024.beta" [dependencies] -Kiosk = { local = "../../../kiosk" } Sui = { local = "../../../crates/sui-framework/packages/sui-framework" } [addresses] diff --git a/examples/turnip-town/move/sources/admin.move b/examples/turnip-town/move/sources/admin.move deleted file mode 100644 index 724521b3d6c99..0000000000000 --- a/examples/turnip-town/move/sources/admin.move +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -/// # Admin -/// -/// Module defining `AdminCap`, the capability held by the game admin, -/// which is used to drive the game simulation. -/// -/// `AdminCap`s can be `copy_`-ed, to allow issuing concurrent -/// transactions (each would need its own `AdminCap`). -module turnip_town::admin { - use sui::object::{Self, ID, UID}; - use sui::tx_context::TxContext; - - friend turnip_town::game; - - struct AdminCap has key, store { - id: UID, - game: ID, - } - - /// Only the `game` module can create brand new `AdminCap`s. - public(friend) fun mint(game: ID, ctx: &mut TxContext): AdminCap { - AdminCap { id: object::new(ctx), game } - } - - /// Create an identical copy of `cap` (with the same privileges). - /// This is useful to support issuing concurrent transactions that - /// need admin authorization. - public fun copy_(cap: &AdminCap, ctx: &mut TxContext): AdminCap { - AdminCap { id: object::new(ctx), game: cap.game } - } - - public fun is_authorized(cap: &AdminCap, game: ID): bool { - cap.game == game - } - - public fun burn(cap: AdminCap) { - let AdminCap { id, game: _ } = cap; - object::delete(id); - } -} diff --git a/examples/turnip-town/move/sources/field.move b/examples/turnip-town/move/sources/field.move index 1466bb35c8f40..ba38c7ef562d4 100644 --- a/examples/turnip-town/move/sources/field.move +++ b/examples/turnip-town/move/sources/field.move @@ -1,48 +1,46 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -/// # Field +/// Defines the `Field` type, which represents a single instance of the game. +/// Fields are installed as Kiosk Apps on the player's Kiosk. The player that +/// owns the Kiosk is said to own the installed `Field`. /// -/// Defines the `Field` type, which is responsible for one instance of -/// the game. `Field`s are stored centrally, in a `Game` instance, -/// and a `Deed` is created that gives its owner access to that -/// `Field` through the `Game`. -/// -/// Although `Field` is not an object, an `address` is generated for -/// it, to identify it in the `Game`. This `address` is also stored -/// in its `Deed`. -/// -/// Any field owner can sow or harvest turnips from the field, but -/// simulating growth and watering can only be done via the `Game` -/// which decides the cadence of simulation and how much water to -/// dispense. +/// Only the owner is able to sow turnips in the field, and take water from +/// their field (which can be used anywhere, including other players' fields). +/// Any player can visit a field to water turnips and harvest them, but +/// harvested turnips always go to the field owner's kiosk. module turnip_town::field { - use std::option::{Self, Option}; - use std::vector; - use sui::math; - use sui::object::{Self, UID}; - use sui::package; - use sui::transfer; - use sui::transfer_policy; - use sui::tx_context::{Self, TxContext}; - use turnip_town::turnip::{Self, Turnip}; + use turnip_town::water::Water; - friend turnip_town::game; + // === Types === + + public struct Field has store { + slots: vector, + } - struct FIELD has drop {} + /// A slot is a single position in the field. + public struct Slot has store { + /// The turnip growing at this position in the field. + turnip: Option, - struct Field has store { - slots: vector>, + /// The water left over at this position, this is consumed over time. water: u64, - } - /// Deed of ownership for a particular field in the game. - struct Deed has key, store { - id: UID, - field: address, + /// The last epoch this slot was simulated at. + last_updated: u64, } + // === Constants === + + /// Field width (number of slots) + const WIDTH: u64 = 4; + + /// Field height (number of slots) + const HEIGHT: u64 = 4; + + // === Errors === + /// Trying to plant in a non-existent slot. const EOutOfBounds: u64 = 0; @@ -58,512 +56,172 @@ module turnip_town::field { /// Turnip is too small to harvest const ETooSmall: u64 = 4; - const WIDTH: u64 = 4; - const HEIGHT: u64 = 4; - - fun init(otw: FIELD, ctx: &mut TxContext) { - let publisher = package::claim(otw, ctx); - - // Make `Deed`'s transferable between Kiosks, but without any - // additional rules. - let (policy, cap) = transfer_policy::new(&publisher, ctx); - transfer::public_share_object(policy); - transfer::public_transfer(cap, tx_context::sender(ctx)); - package::burn_publisher(publisher); - } - - public fun deed_field(deed: &Deed): address { - deed.field - } - - /// Plant a fresh turnip at position (i, j) in `field`. - /// - /// Fails if the position is out of bounds or there is already a - /// turnip there. - public fun sow(field: &mut Field, i: u64, j: u64, ctx: &mut TxContext) { - assert!(i < WIDTH && j < HEIGHT, EOutOfBounds); - - let ix = i + j * HEIGHT; - let slot = vector::borrow_mut(&mut field.slots, ix); + // === Protected Functions === - assert!(option::is_none(slot), EAlreadyFilled); - option::fill(slot, turnip::fresh(ctx)); - } - - /// Harvest the turnip at position (i, j). - /// - /// Fails if the position is out of bounds, if no turnip exists - /// there or the turnip was too small to harvest. - public fun harvest(field: &mut Field, i: u64, j: u64): Turnip { - assert!(i < WIDTH && j < HEIGHT, EOutOfBounds); - - let ix = i + j * HEIGHT; - let slot = vector::borrow_mut(&mut field.slots, ix); - assert!(option::is_some(slot), ENotFilled); - - let turnip = option::extract(slot); - assert!(turnip::can_harvest(&turnip), ETooSmall); - - turnip - } - - /* Protected Functions ****************************************************/ - - /// Create a brand new field. Protected to prevent `Field`s being - /// created but not attached to a game. - public(friend) fun new(ctx: &mut TxContext): (Deed, Field) { - let field = tx_context::fresh_object_address(ctx); - - let slots = vector[]; + /// Create a brand new field. + public(package) fun new(ctx: &TxContext): Field { + let mut slots = vector[]; let total = WIDTH * HEIGHT; - while (vector::length(&slots) < total) { - vector::push_back(&mut slots, option::none()); + while (slots.length() < total) { + slots.push_back(Slot { + turnip: option::none(), + water: 0, + last_updated: ctx.epoch(), + }); }; - ( - Deed { id: object::new(ctx), field }, - Field { slots, water: 0 }, - ) + Field { slots } } - /// Destroy an empty field. Protected to prevent `Field` being - /// destroyed without its associated `Deed` being destroyed. - /// - /// Fails if there are any turnips left in the `field`. - public(friend) fun burn_field(field: Field) { - let Field { slots, water: _ } = field; + /// Destroy a field with turnips potentially in it, as long as they could + /// not be harvested. + public(package) fun burn(mut field: Field, ctx: &TxContext) { + field.simulate(ctx); + + let Field { mut slots } = field; while (!vector::is_empty(&slots)) { - let turnip = vector::pop_back(&mut slots); - assert!(option::is_none(&turnip), ENotEmpty); - option::destroy_none(turnip); + let Slot { turnip, water: _, last_updated: _ } = slots.pop_back(); + if (turnip.is_some()) { + let turnip = turnip.destroy_some(); + assert!(!turnip.can_harvest(), ENotEmpty); + turnip.consume(); + } else { + turnip.destroy_none(); + } }; vector::destroy_empty(slots) } - /// Destroy the deed for a field. Protected to prevent `Deed` - /// being destroyed without its associated `Field` being - /// destroyed. - public(friend) fun burn_deed(deed: Deed) { - let Deed { id, field: _ } = deed; - object::delete(id); - } - - /// Add water to the field. Protected so the game can control how - /// much water is given. - public(friend) fun water(field: &mut Field, amount: u64) { - field.water = field.water + amount; - } - - /// Simulates turnips growing. Protected as only the game module - /// can control when simulation occurs. - public(friend) fun simulate_growth(field: &mut Field, is_sunny: bool) { - let (total_size, count) = count_turnips(field); - - // Not enough water to maintain freshness - if (field.water < total_size) { - field.water = 0; - debit_field_freshness(field); - } else { - field.water = field.water - total_size; - credit_field_freshness(field); - }; - - if (is_sunny) { - let total_growth = math::min(20 * count, field.water); - grow_turnips(field, total_growth / count / 2); - field.water = field.water - total_growth; - }; - - if (field.water > 0) { - debit_field_freshness(field); - }; - - clean_up_field(field); + /// Plant a fresh turnip at position (i, j) in `field`. + /// + /// Fails if the position is out of bounds or there is already a turnip + /// there. + public(package) fun sow( + field: &mut Field, + i: u64, + j: u64, + ctx: &mut TxContext, + ) { + let slot = field.slot_mut(i, j, ctx); + + assert!(slot.turnip.is_none(), EAlreadyFilled); + slot.turnip.fill(turnip::fresh(ctx)); + slot.last_updated = ctx.epoch(); + } + + /// Add water at position (i, j) in `field`. + /// + /// Fails if the postion is out-of-bounds. It is valid to water a slot + /// without a turnip. + public(package) fun water( + field: &mut Field, + i: u64, + j: u64, + water: Water, + ctx: &TxContext, + ) { + let slot = field.slot_mut(i, j, ctx); + slot.water = slot.water + water.value(); } - /* Private Functions ***************************************************/ - /* (Helpers for `simulate_growth`) */ - - fun count_turnips(field: &Field): (u64, u64) { - let slots = &field.slots; - let len = vector::length(slots); - - let (i, size, count) = (0, 0, 0); - while (i < len) { - let slot = vector::borrow(slots, i); - if (option::is_some(slot)) { - let turnip = option::borrow(slot); - size = size + turnip::size(turnip); - count = count + 1; - }; - i = i + 1; - }; - - (size, count) + /// Harvest the turnip at position (i, j). + /// + /// Fails if the position is out of bounds, if no turnip exists there or the + /// turnip was too small to harvest. + public(package) fun harvest( + field: &mut Field, + i: u64, + j: u64, + ctx: &TxContext, + ): Turnip { + let slot = field.slot_mut(i, j, ctx); + + assert!(slot.turnip.is_some(), ENotFilled); + let turnip = slot.turnip.extract(); + + assert!(turnip.can_harvest(), ETooSmall); + turnip } - fun grow_turnips(field: &mut Field, growth: u64) { - let slots = &mut field.slots; - let len = vector::length(slots); - - let i = 0; - while (i < len) { - let slot = vector::borrow_mut(slots, i); - if (option::is_some(slot)) { - let turnip = option::borrow_mut(slot); - turnip::grow(turnip, growth); + /// Bring all the slots in the field up-to-date with the current epoch. + public fun simulate(field: &mut Field, ctx: &TxContext) { + let mut j = 0; + while (j < HEIGHT) { + let mut i = 0; + while (i < WIDTH) { + // Calling slot_mut has the effect of bringing the slot + // up-to-date before returning a reference to it (which is + // immediately discarded). + let _ = field.slot_mut(i, j, ctx); + i = i + 1; }; - i = i + 1; - }; + j = j + 1; + } } - fun debit_field_freshness(field: &mut Field) { - let slots = &mut field.slots; - let len = vector::length(slots); - - let i = 0; - while (i < len) { - let slot = vector::borrow_mut(slots, i); - if (option::is_some(slot)) { - let turnip = option::borrow_mut(slot); - turnip::debit_freshness(turnip); - }; - i = i + 1; - }; - - } + // === Private Functions === - fun credit_field_freshness(field: &mut Field) { - let slots = &mut field.slots; - let len = vector::length(slots); + /// Return the slot at position (i, j), up-to-date as of the epoch in `ctx`. + /// + /// Fails if (i, j) is out-of-bounds. + fun slot_mut(field: &mut Field, i: u64, j: u64, ctx: &TxContext): &mut Slot { + assert!(i < WIDTH && j < HEIGHT, EOutOfBounds); - let i = 0; - while (i < len) { - let slot = vector::borrow_mut(slots, i); - if (option::is_some(slot)) { - let turnip = option::borrow_mut(slot); - turnip::credit_freshness(turnip); - }; - i = i + 1; + let epoch = ctx.epoch(); + let ix = i + j * WIDTH; + let slot = &mut field.slots[ix]; + let days = epoch - slot.last_updated; + slot.last_updated = epoch; + + if (slot.turnip.is_some()) { + let turnip = slot.turnip.borrow_mut(); + turnip.simulate(&mut slot.water, days); + if (!turnip.is_fresh()) { + slot.turnip.extract().consume(); + } }; - } - fun clean_up_field(field: &mut Field) { - let slots = &mut field.slots; - let len = vector::length(slots); - - let i = 0; - while (i < len) { - let slot = vector::borrow_mut(slots, i); - if (option::is_some(slot)) { - if (!turnip::is_fresh(option::borrow(slot))) { - turnip::consume(option::extract(slot)) - } - }; - i = i + 1; - }; + slot } - /* Tests ******************************************************************/ - use sui::test_scenario as ts; + // === Test Helpers === #[test_only] - fun borrow_mut(field: &mut Field, i: u64, j: u64): &mut Turnip { - option::borrow_mut(vector::borrow_mut(&mut field.slots, i + j * WIDTH)) - } - - #[test] - fun test_burn() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - burn_field(field); - burn_deed(deed); - ts::end(ts); - } - - #[test] - #[expected_failure(abort_code = ENotEmpty)] - fun test_burn_failure() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - // Sow a turnip, now the field is not empty. - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - - burn_field(field); - burn_deed(deed); - ts::end(ts); - } - - #[test] - fun test_sow_and_harvest() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - - // Update the sown turnip to be big enough to harvest. - turnip::prepare_for_harvest(borrow_mut(&mut field, 0, 0)); - - let turnip = harvest(&mut field, 0, 0); - turnip::consume(turnip); - - burn_field(field); - burn_deed(deed); - ts::end(ts); - } - - #[test] - #[expected_failure(abort_code = EOutOfBounds)] - fun test_sow_out_of_bounds() { - let ts = ts::begin(@0xA); - let (_deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, WIDTH + 1, 0, ts::ctx(&mut ts)); - abort 1337 - } - - #[test] - #[expected_failure(abort_code = EAlreadyFilled)] - fun test_sow_overlap() { - let ts = ts::begin(@0xA); - let (_deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - abort 1337 - } - - #[test] - #[expected_failure(abort_code = EOutOfBounds)] - fun test_harvest_out_of_bounds() { - let ts = ts::begin(@0xA); - let (_deed, field) = new(ts::ctx(&mut ts)); - - let _turnip = harvest(&mut field, WIDTH + 1, 0); - abort 1337 - } - - #[test] - #[expected_failure(abort_code = ETooSmall)] - fun test_harvest_too_small() { - let ts = ts::begin(@0xA); - let (_deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - let _turnip = harvest(&mut field, 0, 0); - abort 1337 - } - - #[test] - #[expected_failure(abort_code = ENotFilled)] - fun test_harvest_non_existent() { - let ts = ts::begin(@0xA); - let (_deed, field) = new(ts::ctx(&mut ts)); - - let _turnip = harvest(&mut field, 0, 0); - abort 1337 + #[syntax(index)] + /// General access to slots in the field, but only exposed for tests. + public fun borrow(field: &Field, i: u64, j: u64): &Turnip { + field.slots[i + j * WIDTH].turnip.borrow() } - #[test] - fun test_simulation_growth() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - - // Update the sown turnip to be big enough to harvest (even - // without growing) - let turnip = borrow_mut(&mut field, 0, 0); - turnip::prepare_for_harvest(turnip); - let size = turnip::size(turnip); - - let is_sunny = true; - water(&mut field, size + 10); - simulate_growth(&mut field, is_sunny); - - // All the water should be used up. - assert!(field.water == 0, 0); - - let turnip = harvest(&mut field, 0, 0); - - // Turnip grows by half its excess water usage. - assert!(turnip::size(&turnip) == size + 5, 0); - - turnip::consume(turnip); - burn_field(field); - burn_deed(deed); - ts::end(ts); - } - - #[test] - fun test_simulation_dry() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - - // Update the sown turnip to be big enough to harvest (even - // without growing) - let turnip = borrow_mut(&mut field, 0, 0); - turnip::prepare_for_harvest(turnip); - let size = turnip::size(turnip); - - // Not enough water to maintain freshness - let is_sunny = true; - water(&mut field, size - 1); - simulate_growth(&mut field, is_sunny); - - // All the water should be used up. - assert!(field.water == 0, 0); - - let turnip = harvest(&mut field, 0, 0); - - // Turnip does not grow, and it gets less fresh - assert!(turnip::size(&turnip) == size, 0); - assert!(turnip::freshness(&turnip) == 50_00, 0); - - turnip::consume(turnip); - burn_field(field); - burn_deed(deed); - ts::end(ts); - } - - #[test] - fun test_simulation_rot() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - - // Update the sown turnip to be big enough to harvest (even - // without growing) - let turnip = borrow_mut(&mut field, 0, 0); - turnip::prepare_for_harvest(turnip); - let size = turnip::size(turnip); - - // Not enough water to maintain freshness - let is_sunny = true; - water(&mut field, size - 1); - simulate_growth(&mut field, is_sunny); - - // All the water should be used up. - assert!(field.water == 0, 0); - - let turnip = harvest(&mut field, 0, 0); - - // Turnip does not grow, and it gets less fresh - assert!(turnip::size(&turnip) == size, 0); - assert!(turnip::freshness(&turnip) == 50_00, 0); - - turnip::consume(turnip); - burn_field(field); - burn_deed(deed); - ts::end(ts); + #[test_only] + #[syntax(index)] + /// General access to slots in the field, but only exposed for tests. + public fun borrow_mut(field: &mut Field, i: u64, j: u64): &mut Turnip { + field.slots[i + j * WIDTH].turnip.borrow_mut() } - #[test] - fun test_simulation_not_sunny() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - - // Update the sown turnip to be big enough to harvest (even - // without growing) - let turnip = borrow_mut(&mut field, 0, 0); - turnip::prepare_for_harvest(turnip); - let size = turnip::size(turnip); - - // If the weather is not sunny, then nothing grows - let is_sunny = false; - water(&mut field, size + 10); - simulate_growth(&mut field, is_sunny); - - // The water that would have been used for growth remain. - assert!(field.water == 10, 0); - - let turnip = harvest(&mut field, 0, 0); - - // Turnip does not grow, and gets less fresh - assert!(turnip::size(&turnip) == size, 0); - assert!(turnip::freshness(&turnip) == 50_00, 0); - - turnip::consume(turnip); - burn_field(field); - burn_deed(deed); - ts::end(ts); + #[test_only] + public fun is_empty(field: &mut Field, i: u64, j: u64): bool { + field.slots[i + j * WIDTH].turnip.is_none() } - #[test] - fun test_simulation_multi() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - sow(&mut field, 1, 0, ts::ctx(&mut ts)); - - // Update the sown turnips to be big enough to harvest (even - // without growing) - turnip::prepare_for_harvest(borrow_mut(&mut field, 0, 0)); - turnip::prepare_for_harvest(borrow_mut(&mut field, 1, 0)); - - // Make the second turnip bigger, for variety - turnip::prepare_for_harvest(borrow_mut(&mut field, 1, 0)); - - let s0 = turnip::size(borrow_mut(&mut field, 0, 0)); - let s1 = turnip::size(borrow_mut(&mut field, 1, 0)); - - let is_sunny = true; - water(&mut field, s0 + s1 + 10); - simulate_growth(&mut field, is_sunny); - - // All the water should be used up. - assert!(field.water == 0, 0); - - let t0 = harvest(&mut field, 0, 0); - let t1 = harvest(&mut field, 1, 0); - - // Turnips only grow by 2, because there are 5 units of water - // for each, and we grow by half that, rounded down. - assert!(turnip::size(&t0) == s0 + 2, 0); - assert!(turnip::size(&t1) == s1 + 2, 0); - - turnip::consume(t0); - turnip::consume(t1); - - burn_field(field); - burn_deed(deed); - ts::end(ts); - } + #[test_only] + /// Clean-up the field even if it contains turnips in it. + public fun destroy_for_test(field: Field) { + let Field { mut slots } = field; - #[test] - fun test_simulation_cleanup() { - let ts = ts::begin(@0xA); - let (deed, field) = new(ts::ctx(&mut ts)); - - sow(&mut field, 0, 0, ts::ctx(&mut ts)); - turnip::prepare_for_harvest(borrow_mut(&mut field, 0, 0)); - - let is_sunny = true; - let expected_freshness = 50_00; - while (expected_freshness != 0) { - simulate_growth(&mut field, is_sunny); - let turnip = borrow_mut(&mut field, 0, 0); - assert!(expected_freshness == turnip::freshness(turnip), 0); - expected_freshness = expected_freshness / 2; + while (!vector::is_empty(&slots)) { + let Slot { turnip, water: _, last_updated: _ } = slots.pop_back(); + if (turnip.is_some()) { + let turnip = turnip.destroy_some(); + turnip.consume(); + } else { + turnip.destroy_none(); + } }; - // The turnip was cleaned up once its freshness reached zero. - simulate_growth(&mut field, is_sunny); - assert!(expected_freshness == 0, 0); - assert!(option::is_none(vector::borrow(&mut field.slots, 0)), 0); - - burn_field(field); - burn_deed(deed); - ts::end(ts); + vector::destroy_empty(slots) } } diff --git a/examples/turnip-town/move/sources/game.move b/examples/turnip-town/move/sources/game.move index 87bd3fc5fd268..df2951c779583 100644 --- a/examples/turnip-town/move/sources/game.move +++ b/examples/turnip-town/move/sources/game.move @@ -1,163 +1,150 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -/// # Game +/// Acts as an entrypoint to Turnip Town, as a Kiosk app. /// -/// The `game` module is the entrypoint for Turnip Town. On publish, -/// it creates a central table of all active game instances, and -/// protects access to a game instance (a `Field`) via its `Deed`. -/// -/// `simulate_weather` is an admin-only operation that the game admin -/// uses to progress the simulation of the game on a given field -/// (identified by its ID). +/// Other modules in this package expose public(package) functions, which this +/// module calls into after performing the appropriate authorization checks. module turnip_town::game { - use sui::object::{Self, UID}; - use sui::table::{Self, Table}; - use sui::transfer; - use sui::tx_context::{Self, TxContext}; - - use turnip_town::admin::{Self, AdminCap}; - use turnip_town::field::{Self, Deed, Field}; + use sui::kiosk::{Kiosk, KioskOwnerCap}; + use sui::kiosk_extension as app; + use sui::transfer_policy::TransferPolicy; + use turnip_town::field::{Self, Field}; use turnip_town::turnip::Turnip; + use turnip_town::water::{Self, Water, Well}; - struct Game has key, store { - id: UID, - fields: Table, - } + // === Types === - /// No corresponding field for the deed. - const ENoSuchField: u64 = 0; + /// Kiosk App witness -- doubles as a dynamic field key for holding the game + /// state. + public struct EXT() has drop; - /// Admin does not have permissions to update the game. - const ENotAuthorized: u64 = 1; + /// Key for storing the game state in the Kiosk App's bag. + public struct KEY() has copy, drop, store; - /// How much water a field gets when watered by a player. - const WATER_INCREMENT: u64 = 1000; + public struct Game has store { + field: Field, + well: Well, + } - fun init(ctx: &mut TxContext) { - let game = Game { - id: object::new(ctx), - fields: table::new(ctx), - }; + // === Constants === - transfer::public_transfer( - admin::mint(object::id(&game), ctx), - tx_context::sender(ctx), - ); + /// Kiosk App permisions + /// + /// place: To support placing harvested turnips into the kiosk. + const PERMISSIONS: u128 = 1; - transfer::share_object(game); - } + // === Errors === + + /// Game is already installed. + const EAlreadyInstalled: u64 = 0; - /// Create a new field to the `game`. The field starts off empty - /// (no plants, no water). Returns the deed that gives a player - /// control of that field. - public fun new(game: &mut Game, ctx: &mut TxContext): Deed { - let (deed, field) = field::new(ctx); + /// Game is not installed on this Kiosk. + const ENotInstalled: u64 = 1; - table::add( - &mut game.fields, - field::deed_field(&deed), - field, - ); + /// Action can only be performed by the kiosk owner. + const ENotAuthorized: u64 = 2; - deed + // === Public Functions === + + /// Install Turnip Town as a Kiosk App (adds the extension and sets up a new + /// game state). Each kiosk can host at most one game instance. + public fun add(kiosk: &mut Kiosk, cap: &KioskOwnerCap, ctx: &mut TxContext) { + assert!(kiosk.has_access(cap), ENotAuthorized); + assert!(!app::is_installed(kiosk), EAlreadyInstalled); + app::add(EXT(), kiosk, cap, PERMISSIONS, ctx); + let bag = app::storage_mut(EXT(), kiosk); + bag.add(KEY(), Game { + field: field::new(ctx), + well: water::well(ctx), + }); } - /// Destroy the field owned by `deed`. - /// - /// Fails if that field is not empty, or if the field has somehow - /// already been deleted. - public fun burn(deed: Deed, game: &mut Game) { - let faddr = field::deed_field(&deed); - assert!(table::contains(&game.fields, faddr), ENoSuchField); - - let field = table::remove(&mut game.fields, faddr); - field::burn_field(field); - field::burn_deed(deed); + /// Uninstall Turnip Town as a Kiosk App. The field must be empty (any + /// eligible turnips harvested) for this operation to succeed. + public fun remove(kiosk: &mut Kiosk, cap: &KioskOwnerCap, ctx: &TxContext) { + assert!(kiosk.has_access(cap), ENotAuthorized); + assert!(app::is_installed(kiosk), ENotInstalled); + let Game { field, well: _ } = app::storage_mut(EXT(), kiosk).remove(KEY()); + field.burn(ctx); } - /// Sow a turnip at position (i, j) of the field owned by `deed` - /// in `game`. - /// - /// Fails if the field does not exist for this `deed`, or there is - /// already a turnip at that position. + /// Sow a seed at slot `(i, j)` of the field in the game installed on + /// `kiosk`. This is an authorized action, so can only be performed by the + /// owner of the kiosk, and only works if the kiosk has the game installed, + /// and the slot does not already contain a turnip. public fun sow( - deed: &Deed, - game: &mut Game, + kiosk: &mut Kiosk, + cap: &KioskOwnerCap, i: u64, j: u64, ctx: &mut TxContext, ) { - let faddr = field::deed_field(deed); - assert!(table::contains(&game.fields, faddr), ENoSuchField); - - let field = table::borrow_mut(&mut game.fields, faddr); - field::sow(field, i, j, ctx); + assert!(kiosk.has_access(cap), ENotAuthorized); + game_mut(kiosk).field.sow(i, j, ctx) } - /// Water the field owned by `deed`. + /// Fetch water from the well of the game installed on `kiosk`. This is an + /// authorized action, so can only be performed by the owner of the kiosk, + /// and only works if the kiosk has the game installed. /// - /// Fails if the field does not exist for this `deed`. - public fun water(deed: &Deed, game: &mut Game) { - let faddr = field::deed_field(deed); - assert!(table::contains(&game.fields, faddr), ENoSuchField); + /// Access to water is limited to a fixed quantity in each epoch. Attempts + /// to access more water than is available will fail. + public fun fetch_water( + kiosk: &mut Kiosk, + cap: &KioskOwnerCap, + amount: u64, + ctx: &TxContext, + ): Water { + assert!(kiosk.has_access(cap), ENotAuthorized); + game_mut(kiosk).well.fetch(amount, ctx) + } - let field = table::borrow_mut(&mut game.fields, faddr); - field::water(field, WATER_INCREMENT); + /// Water the turnip at cell `(i, j)` on the field in the game installed on + /// `kiosk`. This operation can only be performed on kiosks where the game + /// has been installed, and where the field contains a turnip at the given + /// position. + public fun water( + kiosk: &mut Kiosk, + i: u64, + j: u64, + water: Water, + ctx: &TxContext, + ) { + game_mut(kiosk).field.water(i, j, water, ctx) } - /// Harvest a turnip at position (i, j) of the field owned by - /// `deed`. + /// Harvest a turnip growing at position `(i, j)` on the field in the game + /// installed on `kiosk`. This action can only be performed on kiosks where + /// the game has been installed. /// - /// Fails if the field for this `deed` does not exist, there is no - /// turnip to harvest at this position or that turnip is too small - /// to harvest. - public fun harvest(deed: &Deed, game: &mut Game, i: u64, j: u64): Turnip { - let faddr = field::deed_field(deed); - assert!(table::contains(&game.fields, faddr), ENoSuchField); - - let field = table::borrow_mut(&mut game.fields, faddr); - field::harvest(field, i, j) + /// The harvested Kiosk is placed in the owning field (regardless of who + /// harvested it), and its ID is returned. + public fun harvest( + kiosk: &mut Kiosk, + policy: &TransferPolicy, + i: u64, + j: u64, + ctx: &TxContext, + ): ID { + let turnip = game_mut(kiosk).field.harvest(i, j, ctx); + let id = object::id(&turnip); + app::place(EXT(), kiosk, turnip, policy); + id } - /// Admin-only operation for the game service to simulate weather. - /// - /// Rain is simulated by adding `rain_amount` to the field's water - /// supply. The water supply is then shared among all turnips in - /// the field. - /// - /// Turnips gain 5% freshness (up to a max of 100%) for each - /// simulation tick they have enough water for. - /// - /// Every turnip needs water equal to its size to remain fresh. - /// If there is not enough water to keep all turnips fresh, then - /// freshness halves (turnips dry). + /// Run the simulation across all turnips in the field of the game installed + /// on `kiosk`, so that its state is accurate up to the current epoch. /// - /// If it `is_sunny` and there is water left over, it is - /// distributed evenly between the turnips, to grow them. Every 2 - /// units of water increases turnip size by 1, up to 10 size - /// units. - /// - /// If there is water left over, then freshness also halves - /// (turnips rot). - /// - /// If freshness drops to zero, the turnip has died and will be - /// removed. - /// - /// Fails if the field (represented by its address in the game's table) - /// does not exist. - public fun simulate_weather( - admin: &AdminCap, - game: &mut Game, - rain_amount: u64, - is_sunny: bool, - faddr: address, - ) { - assert!(admin::is_authorized(admin, object::id(game)), ENotAuthorized); - assert!(table::contains(&game.fields, faddr), ENoSuchField); + /// This action can be performed by anyone. + public fun simulate(kiosk: &mut Kiosk, ctx: &TxContext) { + game_mut(kiosk).field.simulate(ctx) + } + + // === Private Functions === - let field = table::borrow_mut(&mut game.fields, faddr); - field::water(field, rain_amount); - field::simulate_growth(field, is_sunny); + fun game_mut(kiosk: &mut Kiosk): &mut Game { + assert!(app::is_installed(kiosk), ENotInstalled); + &mut app::storage_mut(EXT(), kiosk)[KEY()] } } diff --git a/examples/turnip-town/move/sources/royalty_policy.move b/examples/turnip-town/move/sources/royalty_policy.move new file mode 100644 index 0000000000000..f3899c6276d84 --- /dev/null +++ b/examples/turnip-town/move/sources/royalty_policy.move @@ -0,0 +1,60 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// A transfer policy for enforcing royalty fees for turnips. +module turnip_town::royalty_policy { + use sui::coin::Coin; + use sui::math; + use sui::sui::SUI; + use sui::transfer_policy::{ + Self as policy, + TransferPolicy, + TransferPolicyCap, + TransferRequest, + }; + + // === Types === + public struct RULE() has drop; + public struct Config() has store, drop; + + // === Constants === + + /// 1% commission, in basis points + const COMMISSION_BP: u16 = 1_00; + + /// Be paid at least 1 MIST for each transaction. + const MIN_ROYALTY: u64 = 1; + + // === Errors === + + /// Coin used for payment is not enough to cover the royalty. + const EInsufficientAmount: u64 = 0; + + // === Public Functions === + + /// Buyer action: pay the royalty. + public fun pay( + policy: &mut TransferPolicy, + request: &mut TransferRequest, + payment: &mut Coin, + ctx: &mut TxContext, + ) { + let amount = (request.paid() as u128) * (COMMISSION_BP as u128) / 10_000; + let amount = math::max(amount as u64, MIN_ROYALTY); + + assert!(payment.value() >= amount, EInsufficientAmount); + let fee = payment.split(amount, ctx); + policy::add_to_balance(RULE(), policy, fee); + policy::add_receipt(RULE(), request) + } + + // === Protected Functions === + + /// Add the royalty policy to the given transfer policy. + public(package) fun set( + policy: &mut TransferPolicy, + cap: &TransferPolicyCap, + ) { + policy::add_rule(RULE(), policy, cap, Config()); + } +} diff --git a/examples/turnip-town/move/sources/turnip.move b/examples/turnip-town/move/sources/turnip.move index c09f10f36e3b3..cf8641dbd3961 100644 --- a/examples/turnip-town/move/sources/turnip.move +++ b/examples/turnip-town/move/sources/turnip.move @@ -1,55 +1,56 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -/// # Turnip +/// This module defines the `Turnip` NFT, and a transfer policy configured with +/// a royalty. /// -/// This module defines the `Turnip` NFT, and a transfer policy -/// configured with a royalty. -/// -/// Any owner of a turnip can query its properties, but only the -/// `field` module can modify those properties (size and freshness). +/// Any owner of a turnip can query its properties, but modifications are +/// protected (can only be done by other modules in this package, in particular +/// `field`). module turnip_town::turnip { - use sui::object::{Self, UID}; - use sui::package; - use sui::transfer; + use sui::math; + use sui::package::{Self, Publisher}; use sui::transfer_policy; - use sui::tx_context::{Self, TxContext}; - - use kiosk::royalty_rule; + use turnip_town::royalty_policy; - friend turnip_town::field; + // === Types === - struct TURNIP has drop {} + public struct TURNIP has drop {} - struct Turnip has key, store { + public struct Turnip has key, store { id: UID, - /// Size is measured in its own units + + /// Size is measured in its own units. size: u64, + /// Freshness is measured in basis points. freshness: u16, } - /// 1% commission, in basis points - const COMMISSION_BP: u16 = 1_00; - - /// Be paid at least 1 MIST for each transaction. - const MIN_ROYALTY: u64 = 1; + // === Constants === - /// The smallest size that a plant can be harvested at to still - /// get a turnip. - const MIN_SIZE: u64 = 100; + /// The smallest size that a plant can be harvested at to still get a + /// turnip. + const MIN_SIZE: u64 = 50; /// Initially, turnips start out maximally fresh. const MAX_FRESHNESS_BP: u16 = 100_00; - fun init(otw: TURNIP, ctx: &mut TxContext) { - let publisher = package::claim(otw, ctx); - let (policy, cap) = transfer_policy::new(&publisher, ctx); + /// Freshness recovered in a day when a turnip has just enough water (not + /// too much, not too little). + const REFRESH_BP: u16 = 20_00; - royalty_rule::add(&mut policy, &cap, COMMISSION_BP, MIN_ROYALTY); - transfer::public_share_object(policy); - transfer::public_transfer(cap, tx_context::sender(ctx)); - package::burn_publisher(publisher); + /// Maximum size units that can be grown in a day. + const MAX_DAILY_GROWTH: u64 = 20; + + /// If there is more than this much water left at the end of the day, + /// turnips lose their freshness. + const MAX_STAGNANT_WATER: u64 = 100; + + // === Public Functions === + + fun init(otw: TURNIP, ctx: &mut TxContext) { + init_policy(package::claim(otw, ctx), ctx); } /// Turnips that are below the minimum size cannot be harvested. @@ -71,13 +72,13 @@ module turnip_town::turnip { public fun consume(turnip: Turnip) { let Turnip { id, size: _, freshness: _ } = turnip; - object::delete(id); + id.delete(); } - /** Protected Functions ***************************************************/ + // === Protected Functions === /// A brand new turnip (only the `field` module can create these). - public(friend) fun fresh(ctx: &mut TxContext): Turnip { + public(package) fun fresh(ctx: &mut TxContext): Turnip { Turnip { id: object::new(ctx), size: 0, @@ -85,29 +86,70 @@ module turnip_town::turnip { } } - /// Protected function used by `field` module to increase its size. - public(friend) fun grow(turnip: &mut Turnip, growth: u64) { - turnip.size = turnip.size + growth; - } - - /// Protected function used by `field` module to increase - /// freshness, up to a maximum of 100%. - public(friend) fun credit_freshness(turnip: &mut Turnip) { - turnip.freshness = turnip.freshness + 5_00; - if (turnip.freshness > 100_00) { - turnip.freshness = 100_00; + /// Simulate `days` days passing with `turnip` sitting in `water`. + /// + /// Turnips need to consume at least their size in water every day and + /// subsequently can grow up to `MAX_DAILY_GROWTH`, with each unit of growth + /// requiring a unit of water. At the end of the day they cannot be left in + /// more than `MAX_STAGNANT_WATER`. + /// + /// If the turnip has too little or too much water, its freshness halves at + /// the end of the day, otherwise, freshness increases by `REFRESH_BP`, up + /// to `MAX_FRESHNESS_BP`. + public(package) fun simulate( + turnip: &mut Turnip, + water: &mut u64, + mut days: u64, + ) { + while (days > 0) { + days = days - 1; + if (*water < turnip.size) { + turnip.freshness = turnip.freshness / 2; + *water = 0; + continue + }; + + *water = *water - turnip.size; + let growth = math::min(MAX_DAILY_GROWTH, *water); + turnip.size = turnip.size + growth; + *water = *water - growth; + + if (*water > MAX_STAGNANT_WATER) { + turnip.freshness = turnip.freshness / 2; + continue + }; + + turnip.freshness = turnip.freshness + REFRESH_BP; + if (turnip.freshness > MAX_FRESHNESS_BP) { + turnip.freshness = MAX_FRESHNESS_BP; + } } } - /// Protected function used by `field` module to halve freshness. - public(friend) fun debit_freshness(turnip: &mut Turnip) { - turnip.freshness = turnip.freshness / 2; + // === Private Functions === + + #[allow(lint(share_owned, self_transfer))] + fun init_policy(publisher: Publisher, ctx: &mut TxContext) { + let (mut policy, cap) = transfer_policy::new(&publisher, ctx); + + royalty_policy::set(&mut policy, &cap); + transfer::public_share_object(policy); + transfer::public_transfer(cap, ctx.sender()); + publisher.burn(); } - /* Tests ******************************************************************/ + // === Test Helpers === + + #[test_only] + public struct OTW() has drop; + + #[test_only] + public fun test_init(ctx: &mut TxContext) { + init_policy(package::test_claim(OTW(), ctx), ctx); + } #[test_only] - public fun prepare_for_harvest(turnip: &mut Turnip) { - grow(turnip, MIN_SIZE + 1) + public fun prepare_for_harvest_for_test(turnip: &mut Turnip) { + turnip.size = MIN_SIZE + 1; } } diff --git a/examples/turnip-town/move/sources/water.move b/examples/turnip-town/move/sources/water.move new file mode 100644 index 0000000000000..acbff1deed5f3 --- /dev/null +++ b/examples/turnip-town/move/sources/water.move @@ -0,0 +1,91 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Water is a finite resource -- every address is able to request a fixed +/// quantity per epoch, and can use it to water turnips. Water is fetched from +/// Wells and must be used in the same transaction it was fetched in. +module turnip_town::water { + /// Water can be `drop`-ped, but it cannot be `store`d or transferred, to + /// prevent stockpiling -- it must be used in the same transaction it was + /// requested in. + public struct Water has drop { + balance: u64 + } + + /// Well is a store of Water that replenishes every epoch (but does not + /// accumulate -- it reaches the same level at the beginning of every + /// epoch). + public struct Well has store, drop { + last_used: u64, + available: u64, + } + + // === Constants === + + const WATER_PER_EPOCH: u64 = 100; + + // === Errors === + + /// Not enough water to satisfy request. + const ENotEnough: u64 = 0; + + // === Public functions === + + /// Get water from the well. Also replenishes the well for this epoch if + /// that hasn't happened yet. Aborts if there is not enough water in the + /// well to satisfy the request. + public fun fetch(well: &mut Well, amount: u64, ctx: &TxContext): Water { + let epoch = ctx.epoch(); + if (well.last_used < epoch) { + well.available = WATER_PER_EPOCH; + well.last_used = epoch; + }; + + assert!(amount <= well.available, ENotEnough); + well.available = well.available - amount; + + Water { balance: amount } + } + + public fun zero(): Water { + Water { balance: 0 } + } + + public fun split(self: &mut Water, balance: u64): Water { + assert!(self.balance >= balance, ENotEnough); + self.balance = self.balance - balance; + Water { balance } + } + + public fun join(self: &mut Water, water: Water): u64 { + let Water { balance } = water; + self.balance = self.balance + balance; + self.balance + } + + public fun value(self: &Water): u64 { + self.balance + } + + // === Protected functions === + + /// Create a new, filled well. + public(package) fun well(ctx: &TxContext): Well { + Well { + last_used: ctx.epoch(), + available: WATER_PER_EPOCH, + } + } + + // === Test Helpers === + + #[test_only] + public fun per_epoch(): u64 { + WATER_PER_EPOCH + } + + #[test_only] + public fun for_test(balance: u64): Water { + Water { balance } + } +} diff --git a/examples/turnip-town/move/tests/field_tests.move b/examples/turnip-town/move/tests/field_tests.move new file mode 100644 index 0000000000000..a316d66cfce75 --- /dev/null +++ b/examples/turnip-town/move/tests/field_tests.move @@ -0,0 +1,287 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module turnip_town::field_tests { + use sui::test_scenario as ts; + use turnip_town::field; + use turnip_town::water; + + const ALICE: address = @0xA; + + #[test] + fun burn_empty() { + let mut ts = ts::begin(ALICE); + let field = field::new(ts.ctx()); + field.burn(ts.ctx()); + ts.end(); + } + + #[test] + fun burn_non_harvest() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + field.sow(0, 0, ts.ctx()); + field.burn(ts.ctx()); + ts.end(); + } + + #[test] + #[expected_failure(abort_code = field::ENotEmpty)] + fun burn_failure() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + + // Sow a turnip, now the field is not empty. + field.sow(0, 0, ts.ctx()); + field[0, 0].prepare_for_harvest_for_test(); + field.burn(ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = field::ENotEmpty)] + /// The field appears empty, but because of pending simulation activity, it + /// actually is not. + fun burn_failure_latent() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + + // Sow a turnip, now the field is not empty. + field.sow(0, 0, ts.ctx()); + field.water(0, 0, water::for_test(120), ts.ctx()); + + // Advance a number of epochs to allow the turnip to grow + ts.next_epoch(ALICE); + ts.next_epoch(ALICE); + ts.next_epoch(ALICE); + + // This burn will not succeed because the turnip has grown. + field.burn(ts.ctx()); + abort 0 + } + + #[test] + fun sow_and_harvest() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + + field.sow(0, 0, ts.ctx()); + field[0, 0].prepare_for_harvest_for_test(); + let turnip = field.harvest(0, 0, ts.ctx()); + turnip.consume(); + + field.burn(ts.ctx()); + ts.end(); + } + + #[test] + #[expected_failure(abort_code = field::EOutOfBounds)] + fun sow_out_of_bounds() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + field.sow(1000, 1000, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = field::EAlreadyFilled)] + fun sow_overlap() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + field.sow(0, 0, ts.ctx()); + field.sow(0, 0, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = field::EOutOfBounds)] + fun harvest_out_of_bounds() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + let _turnip = field.harvest(1000, 1000, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = field::ETooSmall)] + fun harvest_too_small() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + + field.sow(0, 0, ts.ctx()); + let _turnip = field.harvest(0, 0, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = field::ENotFilled)] + fun harvest_non_existent() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + let _turnip = field.harvest(0, 0, ts.ctx()); + abort 0 + } + + #[test] + fun multiple_epochs() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + + field.sow(0, 0, ts.ctx()); + field.sow(0, 1, ts.ctx()); + field.water(0, 0, water::for_test(60), ts.ctx()); + field.water(0, 1, water::for_test(10), ts.ctx()); + + // Advance a couple of epochs. + ts.next_epoch(ALICE); + ts.next_epoch(ALICE); + + // The slot doesn't change until it's touched again, even though the + // epoch has advanced. + assert!(field[0, 0].size() == 0); + assert!(field[0, 1].size() == 0); + + // Watering the slot will also update its simulation to account for the + // epochs that have passed. + field.water(0, 0, water::zero(), ts.ctx()); + assert!(field[0, 0].size() == 40); + assert!(field[0, 0].freshness() == 100_00); + + // Other slots are not affected until they are also touched. + assert!(field[0, 1].size() == 0); + + field.water(0, 1, water::zero(), ts.ctx()); + assert!(field[0, 1].size() == 10); + assert!(field[0, 1].freshness() == 50_00); + + field.burn(ts.ctx()); + ts.end(); + } + + #[test] + fun tick_rate() { + let mut ts = ts::begin(ALICE); + + // Create two fields and plant them identically -- one will be simulated + // every epoch, and another will be simulated at different epoch + // boundaries, but the results of their simulations should match. + let mut f0 = field::new(ts.ctx()); + let mut f1 = field::new(ts.ctx()); + + // One turnip gets a good amount of water. + f0.sow(0, 0, ts.ctx()); + f0.water(0, 0, water::for_test(100), ts.ctx()); + f1.sow(0, 0, ts.ctx()); + f1.water(0, 0, water::for_test(100), ts.ctx()); + + // Another turnip gets way too much water. + f0.sow(0, 1, ts.ctx()); + f0.water(0, 1, water::for_test(1000), ts.ctx()); + f1.sow(0, 1, ts.ctx()); + f1.water(0, 1, water::for_test(1000), ts.ctx()); + + // The final turnip gets way too little. + f0.sow(0, 2, ts.ctx()); + f0.water(0, 2, water::for_test(10), ts.ctx()); + f1.sow(0, 2, ts.ctx()); + f1.water(0, 2, water::for_test(10), ts.ctx()); + + ts.next_epoch(ALICE); + f0.simulate(ts.ctx()); + + assert!(f0[0, 0].size() == 20); + assert!(f0[0, 1].size() == 20); + assert!(f0[0, 2].size() == 10); + assert!(f0[0, 0].freshness() == 100_00); + assert!(f0[0, 1].freshness() == 50_00); + assert!(f0[0, 2].freshness() == 100_00); + + ts.next_epoch(ALICE); + f0.simulate(ts.ctx()); + f1.simulate(ts.ctx()); + + assert!(f0[0, 0].size() == 40); + assert!(f0[0, 1].size() == 40); + assert!(f0[0, 2].size() == 10); + assert!(f0[0, 0].freshness() == 100_00); + assert!(f0[0, 1].freshness() == 25_00); + assert!(f0[0, 2].freshness() == 50_00); + + assert!(f1[0, 0].size() == 40); + assert!(f1[0, 1].size() == 40); + assert!(f1[0, 2].size() == 10); + assert!(f1[0, 0].freshness() == 100_00); + assert!(f1[0, 1].freshness() == 25_00); + assert!(f1[0, 2].freshness() == 50_00); + + ts.next_epoch(ALICE); + f0.simulate(ts.ctx()); + + assert!(f0[0, 0].size() == 40); + assert!(f0[0, 1].size() == 60); + assert!(f0[0, 2].size() == 10); + assert!(f0[0, 0].freshness() == 100_00); + assert!(f0[0, 1].freshness() == 12_50); + assert!(f0[0, 2].freshness() == 25_00); + + ts.next_epoch(ALICE); + f0.simulate(ts.ctx()); + + assert!(f0[0, 0].size() == 40); + assert!(f0[0, 1].size() == 80); + assert!(f0[0, 2].size() == 10); + assert!(f0[0, 0].freshness() == 50_00); + assert!(f0[0, 1].freshness() == 6_25); + assert!(f0[0, 2].freshness() == 12_50); + + // Top-up the turnip we're supposed to be caring for properly. + f0.water(0, 0, water::for_test(60), ts.ctx()); + f1.water(0, 0, water::for_test(60), ts.ctx()); + + ts.next_epoch(ALICE); + + f0.simulate(ts.ctx()); + f1.simulate(ts.ctx()); + + assert!(f0[0, 0].size() == 60); + assert!(f0[0, 1].size() == 100); + assert!(f0[0, 2].size() == 10); + assert!(f0[0, 0].freshness() == 70_00); + assert!(f0[0, 1].freshness() == 3_12); + assert!(f0[0, 2].freshness() == 6_25); + + assert!(f1[0, 0].size() == 60); + assert!(f1[0, 1].size() == 100); + assert!(f1[0, 2].size() == 10); + assert!(f1[0, 0].freshness() == 70_00); + assert!(f1[0, 1].freshness() == 3_12); + assert!(f1[0, 2].freshness() == 6_25); + + f0.destroy_for_test(); + f1.destroy_for_test(); + ts.end(); + } + + #[test] + fun clean_up() { + let mut ts = ts::begin(ALICE); + let mut field = field::new(ts.ctx()); + + field.sow(0, 0, ts.ctx()); + field.water(0, 0, water::for_test(10), ts.ctx()); + + // Repeatedly supply less than the necessary water -- the turnip will + // lose freshness over time, until it is cleaned up. + let mut i = 15; + while (i > 0) { + ts.next_epoch(ALICE); + field.water(0, 0, water::for_test(1), ts.ctx()); + i = i - 1; + }; + + // Eventually the simulation cleans up the turnip. + assert!(field.is_empty(0, 0)); + field.burn(ts.ctx()); + ts.end(); + } +} diff --git a/examples/turnip-town/move/tests/game_tests.move b/examples/turnip-town/move/tests/game_tests.move new file mode 100644 index 0000000000000..99abb3859f992 --- /dev/null +++ b/examples/turnip-town/move/tests/game_tests.move @@ -0,0 +1,161 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// These tests mainly check authorization logic, as the game module +/// delegates to other modules for actual logic. Tests for that logic +/// are found in the corresponding test modules. +module turnip_town::game_tests { + use sui::kiosk::{Self, Kiosk, KioskOwnerCap}; + use sui::test_scenario::{Self as ts, Scenario}; + use sui::transfer_policy::TransferPolicy; + use turnip_town::game; + use turnip_town::turnip::{Self, Turnip}; + use turnip_town::water; + + const ALICE: address = @0xA; + + /// Pretend one-time witness for test purposes. + public struct OTW() has drop; + + #[test] + fun add_remove() { + let mut ts = ts::begin(ALICE); + let (mut kiosk, cap) = kiosk::new(ts.ctx()); + + game::add(&mut kiosk, &cap, ts.ctx()); + game::remove(&mut kiosk, &cap, ts.ctx()); + + kiosk.close_and_withdraw(cap, ts.ctx()).burn_for_testing(); + ts.end(); + } + + #[test] + #[expected_failure(abort_code = game::EAlreadyInstalled)] + fun double_add() { + let mut ts = ts::begin(ALICE); + let (mut kiosk, cap) = kiosk::new(ts.ctx()); + + game::add(&mut kiosk, &cap, ts.ctx()); + game::add(&mut kiosk, &cap, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = game::ENotAuthorized)] + fun add_unauthorized() { + let mut ts = ts::begin(ALICE); + let (mut k0, _c) = kiosk::new(ts.ctx()); + let (mut _k, c1) = kiosk::new(ts.ctx()); + + game::add(&mut k0, &c1, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = game::ENotAuthorized)] + fun remove_unauthorized() { + let mut ts = ts::begin(ALICE); + let (mut k0, c0) = kiosk::new(ts.ctx()); + let (mut _k, c1) = kiosk::new(ts.ctx()); + + game::add(&mut k0, &c0, ts.ctx()); + game::remove(&mut k0, &c1, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = game::ENotInstalled)] + fun remove_nonexistent() { + let mut ts = ts::begin(ALICE); + let (mut kiosk, cap) = kiosk::new(ts.ctx()); + + game::remove(&mut kiosk, &cap, ts.ctx()); + abort 0 + } + + #[test] + #[expected_failure(abort_code = game::ENotInstalled)] + fun uninstalled_sow() { + let mut ts = ts::begin(ALICE); + let (mut kiosk, cap) = kiosk::new(ts.ctx()); + game::sow(&mut kiosk, &cap, 0, 0, ts.ctx()); + abort 0 + } + + #[test] + fun authorized_sow() { + let (mut ts, mut kiosk, cap) = setup(); + game::sow(&mut kiosk, &cap, 0, 0, ts.ctx()); + tear_down(ts, kiosk, cap); + } + + #[test] + #[expected_failure(abort_code = game::ENotAuthorized)] + fun unauthorized_sow() { + let (mut ts, mut k0, _c) = setup(); + let (_k, c1) = kiosk::new(ts.ctx()); + game::sow(&mut k0, &c1, 0, 0, ts.ctx()); + abort 0 + } + + #[test] + fun authorized_fetch_water() { + let (mut ts, mut kiosk, cap) = setup(); + let _ = game::fetch_water(&mut kiosk, &cap, 1, ts.ctx()); + tear_down(ts, kiosk, cap); + } + + #[test] + #[expected_failure(abort_code = game::ENotAuthorized)] + fun unauthorized_fetch_water() { + let (mut ts, mut k0, _c) = setup(); + let (_k, c1) = kiosk::new(ts.ctx()); + let _ = game::fetch_water(&mut k0, &c1, 1, ts.ctx()); + abort 0 + } + + #[test] + fun authorized_harvest() { + let (mut ts, mut kiosk, cap) = setup(); + game::sow(&mut kiosk, &cap, 0, 0, ts.ctx()); + game::water(&mut kiosk, 0, 0, water::for_test(150), ts.ctx()); + + // Wait some time for the turnip to grow -- otherwise it won't + // be big enough. + ts.next_epoch(ALICE); + + // Calling simulate is not required every epoch, but it + // shouldn't do any harm. + game::simulate(&mut kiosk, ts.ctx()); + + ts.next_epoch(ALICE); + ts.next_epoch(ALICE); + + let policy: TransferPolicy = ts.take_shared(); + let id = game::harvest(&mut kiosk, &policy, 0, 0, ts.ctx()); + ts::return_shared(policy); + + let turnip: Turnip = kiosk.take(&cap, id); + assert!(turnip.size() == 60); + assert!(turnip.freshness() == 90_00); + + turnip.consume(); + tear_down(ts, kiosk, cap); + } + + fun setup(): (Scenario, Kiosk, KioskOwnerCap) { + let mut ts = ts::begin(ALICE); + let (mut kiosk, cap) = kiosk::new(ts.ctx()); + + turnip::test_init(ts.ctx()); + game::add(&mut kiosk, &cap, ts.ctx()); + + (ts, kiosk, cap) + } + + fun tear_down(mut ts: Scenario, mut kiosk: Kiosk, cap: KioskOwnerCap) { + game::remove(&mut kiosk, &cap, ts.ctx()); + kiosk.close_and_withdraw(cap, ts.ctx()).burn_for_testing(); + ts.end(); + } +} diff --git a/examples/turnip-town/move/tests/royalty_policy_tests.move b/examples/turnip-town/move/tests/royalty_policy_tests.move new file mode 100644 index 0000000000000..e6073797139d2 --- /dev/null +++ b/examples/turnip-town/move/tests/royalty_policy_tests.move @@ -0,0 +1,86 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module turnip_town::royalty_policy_tests { + use sui::coin; + use sui::sui::SUI; + use sui::test_scenario as ts; + use sui::transfer_policy as policy; + use sui::transfer_policy_tests as test; + use turnip_town::royalty_policy as royalty; + + const ALICE: address = @0xA; + + #[test] + fun normal_flow() { + let mut ts = ts::begin(ALICE); + + let (mut policy, cap) = test::prepare(ts.ctx()); + royalty::set(&mut policy, &cap); + + let mut request = policy::new_request( + test::fresh_id(ts.ctx()), + 100_000, + test::fresh_id(ts.ctx()), + ); + + // Commission is 1%, so the coin needs to contain at least 1_000 MIST. + let mut coin = coin::mint_for_testing(1_500, ts.ctx()); + royalty::pay(&mut policy, &mut request, &mut coin, ts.ctx()); + policy::confirm_request(&policy, request); + + let remainder = coin::burn_for_testing(coin); + let profits = test::wrapup(policy, cap, ts.ctx()); + + assert!(remainder == 500); + assert!(profits == 1_000); + ts.end(); + } + + #[test] + fun minimum_royalty() { + let mut ts = ts::begin(ALICE); + + let (mut policy, cap) = test::prepare(ts.ctx()); + royalty::set(&mut policy, &cap); + + let mut request = policy::new_request( + test::fresh_id(ts.ctx()), + 99, + test::fresh_id(ts.ctx()), + ); + + // Commission is 1%, which would usually round down to 0, but the policy + // also has a minimum royalty of 1. + let mut coin = coin::mint_for_testing(10, ts.ctx()); + royalty::pay(&mut policy, &mut request, &mut coin, ts.ctx()); + policy::confirm_request(&policy, request); + + let remainder = coin::burn_for_testing(coin); + let profits = test::wrapup(policy, cap, ts.ctx()); + + assert!(remainder == 9); + assert!(profits == 1); + ts.end(); + } + + #[test] + #[expected_failure(abort_code = royalty::EInsufficientAmount)] + fun insufficient_amount() { + let mut ts = ts::begin(ALICE); + + let (mut policy, cap) = test::prepare(ts.ctx()); + royalty::set(&mut policy, &cap); + + let mut request = policy::new_request( + test::fresh_id(ts.ctx()), + 100_000, + test::fresh_id(ts.ctx()), + ); + + // Commission is 1%, so the coin needs to contain at least 1_000 MIST. + let mut coin = coin::mint_for_testing(999, ts.ctx()); + royalty::pay(&mut policy, &mut request, &mut coin, ts.ctx()); + abort 0 + } +} diff --git a/examples/turnip-town/move/tests/turnip_tests.move b/examples/turnip-town/move/tests/turnip_tests.move new file mode 100644 index 0000000000000..a13e0737db7e8 --- /dev/null +++ b/examples/turnip-town/move/tests/turnip_tests.move @@ -0,0 +1,85 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module turnip_town::turnip_tests { + use sui::test_scenario as ts; + use turnip_town::turnip; + + #[test] + fun fresh() { + let mut ts = ts::begin(@0x0); + let turnip = turnip::fresh(ts.ctx()); + + assert!(turnip.size() == 0); + assert!(turnip.freshness() == 100_00); + assert!(!turnip.can_harvest()); + assert!(turnip.is_fresh()); + + turnip.consume(); + ts.end(); + } + + #[test] + fun simulate() { + let mut ts = ts::begin(@0x0); + let mut turnip = turnip::fresh(ts.ctx()); + + let mut w = 10; + turnip.simulate(&mut w, 1); + assert!(turnip.size() == 10); + assert!(turnip.freshness() == 100_00); + assert!(w == 0); + + // Leave water behind after simulation. + w = 100; + turnip.simulate(&mut w, 1); + assert!(turnip.size() == 30); + assert!(turnip.freshness() == 100_00); + assert!(w == 70); + + // Simulate multiple days. + // Day 1: 150 - 30 - 20 = 100 + // Day 2: 100 - 50 - 20 = 30 + // Day 3: 30 - 30 = 0 (freshness halves). + + w = 150; + turnip.simulate(&mut w, 3); + assert!(turnip.size() == 70); + assert!(turnip.freshness() == 50_00); + assert!(w == 0); + + // Recovering some freshness + w = 100; + turnip.simulate(&mut w, 1); + assert!(turnip.size() == 90); + assert!(turnip.freshness() == 70_00); + assert!(w == 10); + + // Growth while water-logged. + // Day 1: 1000 - 90 - 20 = 890 fresh: 35_00 + // Day 2: 890 - 110 - 20 = 760 fresh: 17_50 + // Day 3: 760 - 130 - 20 = 610 fresh: 8_75 + + w = 1000; + turnip.simulate(&mut w, 3); + assert!(turnip.size() == 150); + assert!(turnip.freshness() == 8_75); + assert!(w == 610); + + // Growth while in drought. + // Day 1: fresh: 4_37 + // Day 2: fresh: 2_18 + // Day 3: fresh: 1_09 + // Day 4: fresh: 54 + // Day 4: fresh: 27 + + w = 100; + turnip.simulate(&mut w, 5); + assert!(turnip.size() == 150); + assert!(turnip.freshness() == 27); + assert!(w == 0); + + turnip.consume(); + ts.end(); + } +} diff --git a/examples/turnip-town/move/tests/water_tests.move b/examples/turnip-town/move/tests/water_tests.move new file mode 100644 index 0000000000000..8f8d4bbfe925f --- /dev/null +++ b/examples/turnip-town/move/tests/water_tests.move @@ -0,0 +1,76 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module turnip_town::water_tests { + use sui::test_scenario as ts; + use turnip_town::water; + + const ALICE: address = @0xA; + + #[test] + fun zero() { + assert!(water::zero().value() == 0); + } + + #[test] + fun fetch_water() { + let mut ts = ts::begin(ALICE); + + let mut well = water::well(ts.ctx()); + let water = well.fetch(water::per_epoch(), ts.ctx()); + assert!(water.value() == water::per_epoch()); + + ts.end(); + } + + #[test] + #[expected_failure(abort_code = water::ENotEnough)] + fun fetch_too_much_water() { + let mut ts = ts::begin(ALICE); + + let mut well = water::well(ts.ctx()); + let _ = well.fetch(water::per_epoch(), ts.ctx()); + let _ = well.fetch(1, ts.ctx()); + + abort 0 + } + + #[test] + fun well_replenish() { + let mut ts = ts::begin(ALICE); + + let mut well = water::well(ts.ctx()); + let water = well.fetch(water::per_epoch(), ts.ctx()); + assert!(water.value() == water::per_epoch()); + + ts.next_epoch(ALICE); + let water = well.fetch(1, ts.ctx()); + assert!(water.value() == 1); + + ts.end(); + } + + #[test] + fun water_split() { + let mut water = water::for_test(42); + let drop = water.split(5); + + assert!(water.value() == 37); + assert!(drop.value() == 5); + } + + #[test] + #[expected_failure(abort_code = water::ENotEnough)] + fun water_split_too_much() { + let mut water = water::for_test(42); + let _ = water.split(43); + } + + #[test] + fun water_join() { + let mut water = water::for_test(42); + let drop = water.split(5); + water.join(drop); + assert!(water.value() == 42); + } +}