diff --git a/ti_bp/manifest.json b/ti_bp/manifest.json index daf9ef9..19933b6 100644 --- a/ti_bp/manifest.json +++ b/ti_bp/manifest.json @@ -12,7 +12,20 @@ "type": "data", "uuid": "a06b47ef-e0d9-4154-94c1-9a6bd86e506f", "version": "1.0.0" + }, + { + "description": "Tamagotchi Scripting", + "type": "script", + "uuid": "3dec0970-c9b8-4e64-886a-cdc237cf938d", + "version": "1.0.0", + "entry": "scripts/main.js" } + ], + "dependencies": [ + { + "module_name":"@minecraft/server", + "version":"2.10.0" + } ], "metadata": { "authors": ["Momoko"], diff --git a/ti_bp/scripts/Ridable.js b/ti_bp/scripts/Ridable.js new file mode 100644 index 0000000..e69de29 diff --git a/ti_bp/scripts/config/houses.js b/ti_bp/scripts/config/houses.js new file mode 100644 index 0000000..e69de29 diff --git a/ti_bp/scripts/config/regions.js b/ti_bp/scripts/config/regions.js new file mode 100644 index 0000000..560ff2a --- /dev/null +++ b/ti_bp/scripts/config/regions.js @@ -0,0 +1,17 @@ +/** + * @typedef {object} Region + * @property {string} nameKey + * @property {number} order + * @property {boolean} unlockedAtStart + * @property {number} unlockedCost + */ + +/** + * @type {Record} + */ +export const REGIONS = { + lagoon: {nameKey: "tama.region.lagoon.name", order: 0, unlockedAtStart: true, unlockedCost: 0}, + farmland: {nameKey: "tama.region.farmland.name", order: 1, unlockedAtStart: false, unlockedCost: 100}, + seaside: {nameKey: "tama.region.seaside.name", order: 2, unlockedAtStart: false, unlockedCost: 500}, + skyland: {nameKey: "tama.region.skyland.name", order: 3, unlockedAtStart: false, unlockedCost: 1000} +}; diff --git a/ti_bp/scripts/config/tamas.js b/ti_bp/scripts/config/tamas.js new file mode 100644 index 0000000..e69de29 diff --git a/ti_bp/scripts/core/commands.js b/ti_bp/scripts/core/commands.js new file mode 100644 index 0000000..5384c15 --- /dev/null +++ b/ti_bp/scripts/core/commands.js @@ -0,0 +1,234 @@ +import { + CommandPermissionLevel, + CustomCommandStatus, + CustomCommandParamType, + system, +} from "@minecraft/server"; +import { REGIONS } from "../config/regions.js"; +import { resetGates, resetGate, unlockGate, unlockGates, + updateBalance, returnBalance, printGateState} from "../save/state.js"; +import { unlockRegion } from "../systems/progression.js"; + +//the system calls this function we do not +//our import into main runs the first line of this etc +system.beforeEvents.startup.subscribe(({ customCommandRegistry }) => { + + + + + customCommandRegistry.registerEnum("rzb_tamagotchi:region", Object.keys(REGIONS)); + + customCommandRegistry.registerCommand( + { + name: "rzb_tamagotchi:print_gates_states", + description: "This command prints the gate states", + permissionLevel: CommandPermissionLevel.Host, + cheatsRequired: true, + }, + () => { + system.run(() => { + printGateState(); + }); + + return { + status: CustomCommandStatus.Success + }; + } + ); + + + + + customCommandRegistry.registerEnum("rzb_tamagotchi:region", Object.keys(REGIONS)); + + customCommandRegistry.registerCommand( + { + name: "rzb_tamagotchi:reset_gates_all", + description: "This command resets all gates to default", + permissionLevel: CommandPermissionLevel.Host, + cheatsRequired: true, + }, + () => { + system.run(() => { + resetGates(); + }); + + return { + status: CustomCommandStatus.Success, + message: "Gates reset to default", + }; + } + ); + + + + + customCommandRegistry.registerCommand( + { + name: "rzb_tamagotchi:reset_gate", + description: "This command resets a single gate", + permissionLevel: CommandPermissionLevel.Host, + cheatsRequired: true, + mandatoryParameters: [ + { + // Use the enum by setting the name to the enum name + name: "rzb_tamagotchi:region", + type: CustomCommandParamType.Enum, + }, + ], + }, + (origin, region) => { + system.run(() => { + resetGate(region); + }); + + return { + status: CustomCommandStatus.Success, + message: "Gate ${REGION[id]} reset to default", + }; + }); + + + + + customCommandRegistry.registerCommand( + { + name: "rzb_tamagotchi:unlocks_gates_all", + description: "This command unlocks all gates", + permissionLevel: CommandPermissionLevel.Host, + cheatsRequired: true, + }, + () => { + system.run(() => { + unlockGates(); + }); + + return { + status: CustomCommandStatus.Success, + message: "All gates Unlocked", + }; + } + ); + + + + + customCommandRegistry.registerCommand( + { + name: "rzb_tamagotchi:unlock_gate", + description: "This command unlocks a single gate", + permissionLevel: CommandPermissionLevel.Host, + cheatsRequired: true, + mandatoryParameters: [ + { + // Use the enum by setting the name to the enum name + name: "rzb_tamagotchi:region", + type: CustomCommandParamType.Enum, + }, + ], + }, + (origin, region) => { + system.run(() => { + unlockGate(region, true); + }); + + return { + status: CustomCommandStatus.Success, + message: `Gate ${REGIONS[id]} unlocked`, + }; + }), + + + + + customCommandRegistry.registerCommand( + { + name: "rzb_tamagotchi:request_unlock", + description: "This command calls unlockRegion(), for entities", + permissionLevel: CommandPermissionLevel.Any, + cheatsRequired: false, + mandatoryParameters: [ + { + // Use the enum by setting the name to the enum name + name: "rzb_tamagotchi:region", + type: CustomCommandParamType.Enum, + }, + ], + }, + (origin, region) => { + system.run(() => { + const result = unlockRegion(region); + + // no entity to talk to when run from a command block or console + if (!origin.sourceEntity) { return; } + + if (result.ok) { + origin.sourceEntity.sendMessage(`${region} unlocked`); + return; + } + + // reason codes are not display text - this is developer-facing + // for now. Player copy belongs in the lang file later. + let why; + switch (result.reason) { + case "already_unlocked": + why = `${region} is already unlocked`; + break; + case "missing_prior_region": + why = result.requires + ? `You must unlock ${result.requires} first` + : `${region} has nothing before it in the chain`; + break; + case "insufficient_funds": + why = `You need ${result.cost} coins, you have ${result.balance}`; + break; + case "unknown_region": + why = `Unknown region: ${region}`; + break; + default: + why = `Cannot unlock ${region}`; + } + + origin.sourceEntity.sendMessage(why); + }); + + return { + status: CustomCommandStatus.Success, + message: `Unlock requested for ${region}`, + }; + }), + + + + + customCommandRegistry.registerCommand( + { + name: "rzb_tamagotchi:balance_update", + description: "This command updates the player balance", + permissionLevel: CommandPermissionLevel.Host, + cheatsRequired: true, + mandatoryParameters: [ + { + name: "rzb_tamagotchi:cost", + type: CustomCommandParamType.Integer, + }, + ], + }, + (origin, cost) => { + system.run(() => { //deferred tick so we get return message first + updateBalance(cost); + + // the new balance only exists after the deferred update runs, + // so report it here rather than in the return value below + if (origin.sourceEntity) { + origin.sourceEntity.sendMessage(`Your balance is ${returnBalance()}`); + } + }); + + return { + status: CustomCommandStatus.Success, + message: `Balance changed by ${cost}`, + }; + } +)}) +returnGateState \ No newline at end of file diff --git a/ti_bp/scripts/core/log.js b/ti_bp/scripts/core/log.js new file mode 100644 index 0000000..8811543 --- /dev/null +++ b/ti_bp/scripts/core/log.js @@ -0,0 +1,7 @@ +const DEBUG = true;; +const PREFIX = "[TAMA]"; + +export function log(message) { + if(!DEBUG) return; + console.warn(`${PREFIX} : ${message}`); +} \ No newline at end of file diff --git a/ti_bp/scripts/main.js b/ti_bp/scripts/main.js new file mode 100644 index 0000000..69aceaa --- /dev/null +++ b/ti_bp/scripts/main.js @@ -0,0 +1,25 @@ +import { log } from "./core/log.js"; +import { load } from "./save/state.js"; +import { getState } from "./save/state.js"; +import "./core/commands.js" +import {world} from "@minecraft/server"; +log("main.js loaded)"); + +/* +Dynamic properties have to exist post worldLoad + +1.9 -> 2.0 scripts now load before world load +Grabbed data will throw an exception +if we don't do things after worldLoad +You can't serve a pizza if you don't have a pizza + +Players, entities, blocks +Time of day, Gamemode, difficulty +*/ +world.afterEvents.worldLoad.subscribe(() => { + load(); + console.log(JSON.stringify(getState())); +}); + +//good for debugging gate values +//console.log(JSON.stringify(getState())); \ No newline at end of file diff --git a/ti_bp/scripts/save/state.js b/ti_bp/scripts/save/state.js new file mode 100644 index 0000000..ea299db --- /dev/null +++ b/ti_bp/scripts/save/state.js @@ -0,0 +1,147 @@ +import { REGIONS } from "../config/regions.js"; +import { log } from "../core/log.js"; +import { world } from "@minecraft/server"; + +const KEY = "tama.progress"; +const VERSION = 1; //For updating the json in the save + +/** + * The in-memory copy of the whole save document, so we don't re-read the + * world's dynamic properties every time something needs a value. + * Null until load() runs. saveState() writes it back out to the world save. + * + * cache = { + * version: 1, + * coins: 0, + * gates: { + * lagoon: true, + * farmland: false, + * seaside: false, + * skyland: false + * } + * } + */ +let cache = null; + +/** + * Reads the save document out of the world and puts it in `cache`. + * + * Nothing stored under KEY yet means a brand-new world - getDynamicProperty + * returns undefined - so build the defaults and write them straight back, + * which is what creates the save. + * + * Otherwise the stored value is JSON text, so parse it back into an object. + */ +export function load(){ + const raw = world.getDynamicProperty(KEY); + //let raw = undefined; + //good for debugging before the value is updated + //console.log(raw); + + if(raw === undefined) { + cache = buildDefaults(); + saveState(); + } else { + //good for debugging post update + //console.log(raw); + cache = JSON.parse(raw); + } + if(cache.version < VERSION) { + //if(version < (VERSION) -> add attribute etc)) + log("=================================="); + log(`version: ${cache.version}, code expects ${VERSION}`); + log("=================================="); + //if attribute === undefined -> set default value + cache.version = VERSION; + } +} + +/** + * Builds a fresh save document for a world that has never been played. + * + * `gates` is an object (not an array) filled by looping REGIONS and copying + * each region's unlockedAtStart value, so adding a region to the config + * automatically adds its gate here with no change to this function. + */ +function buildDefaults() { +const gates = {}; +const coins = 0; +for(const id in REGIONS) +{ +gates[id] = REGIONS[id].unlockedAtStart; +} +return{version: VERSION, gates: gates, coins: coins}; +} + +/** + * Returns the cached save document. + * This is the live object, not a copy - anything that mutates what comes back + * must call saveState() afterwards, or the change is lost on world close. + */ +export function getState() { + return cache; +} + +/** +* buildDefaults returns a value, +* using .gates returns just the +* gates value rather than coins +* and other default data +*/ +export function resetGates() { + cache.gates = buildDefaults().gates; + saveState(); +} +export function resetGate(id) { + //resets the gate from the config/region config + cache.gates[id] = REGIONS[id].unlockedAtStart; + saveState(); +} + +/** + * Sets a region's gate to T/F and persists the change. + * e.g. updateGate("farmland", state) -> + * cache.gates["farmland"] = state + * + * undefined will remove a gate from the cache + */ +export function unlockGate(nKey, state) { +cache.gates[nKey] = state; +saveState(); +} + +export function unlockGates() { +for(const id in REGIONS) { +cache.gates[id] = true; +} +saveState(); +} + +export function updateBalance(cost) { + cache.coins += cost; + saveState(); +} + +export function returnBalance(){ + const value = cache.coins; + return value; +} + +export function printGateState(){ +console.log(JSON.stringify(getState().gates)); +} + +/** + * Writes the cache out to the world save, where it survives the world closing. + * + * Returns early if nothing has been loaded yet - writing a null cache would + * store the text "null" and corrupt the save. + * + * Objects are not a permitted dynamic property value (only boolean, number, + * string and Vector3), so the document is converted to JSON text first. + */ +export function saveState() { + if(cache === null) return; + const text = JSON.stringify(cache); + world.setDynamicProperty(KEY, text); +} \ No newline at end of file diff --git a/ti_bp/scripts/systems/progression.js b/ti_bp/scripts/systems/progression.js new file mode 100644 index 0000000..a586034 --- /dev/null +++ b/ti_bp/scripts/systems/progression.js @@ -0,0 +1,95 @@ +import { REGIONS } from "../config/regions.js" +import { getState, updateBalance, unlockGate, returnBalance } from "../save/state.js" + +/** + * Can this region be unlocked right now, and if not, why not? + * + * Returns an outcome object rather than a boolean so callers can explain the + * refusal to the player. `reason` is a code, not display text - whoever shows + * it is responsible for turning it into a translated message. + * + * { ok: true } + * { ok: false, reason: "unknown_region" } developer error + * { ok: false, reason: "already_unlocked" } + * { ok: false, reason: "missing_prior_region", requires: "farmland" } + * { ok: false, reason: "insufficient_funds", cost: 500, balance: 120 } + * + * @param {string} regionID - a REGIONS key, e.g. "farmland" + */ +export function canUnlock(regionID) { + + /* + the region's whole config record: + { nameKey, order, unlockedAtStart, unlockedCost} + */ + const region = REGIONS[regionID]; + if (region === undefined) { + return { ok: false, reason: "unknown_region" }; + } + // order number from the REGIONS section we keyed + const order = region.order; + // we return the full record. + const gates = getState().gates; + /* + if the records value for that key's order is + true: unlocked + */ + if (gates[regionID] === true) { + return { ok: false, reason: "already_unlocked" }; + } + + /* + WE ARE KEYING FOR THE KEY VALUE'S ORDER PRIOR TO PASSED REGION{RegionID} + the prior value has to be true for the passed region to unlock + */ + let answer = null; + for (const id in REGIONS) { + // never pass lagoon otherwise all conditions fail + if (REGIONS[id].order === order - 1) { + answer = id; + } + } + + // if first region is auto unlocked we will never meet this condition + if (answer === null) { + return { ok: false, reason: "missing_prior_region", requires: null }; + } + + //the region before this one must be open first + if (gates[answer] !== true) { + return { ok: false, reason: "missing_prior_region", requires: answer }; + } + + //they have to be able to pay for it. + const cost = region.unlockedCost; + const balance = returnBalance(); + if (balance < cost) { + return { ok: false, reason: "insufficient_funds", cost: cost, balance: balance }; + } + + return { ok: true }; +} + +/** + * Unlocks a region if the rules allow it, charges the cost, and saves. + * object canUnlock produced and can explain what went wrong. + * + * @param {string} regionID - a REGIONS key, e.g. "farmland" + */ +export function unlockRegion(regionID) { + + const result = canUnlock(regionID); + if (!result.ok) { + return result; + } + + unlockGate(regionID, true); + + /* + always make sure things that cost money are passed negative, + because otherwise it would add to the balance + */ + updateBalance(-REGIONS[regionID].unlockedCost); + + return { ok: true }; +} diff --git a/ti_rp/texts/en_us.lang b/ti_rp/texts/en_us.lang index 1c5de72..1340d8a 100644 --- a/ti_rp/texts/en_us.lang +++ b/ti_rp/texts/en_us.lang @@ -1,2 +1,7 @@ pack.name=The Tamagotchi Resource Pack -pack.description=A Resource pack for the Tamagotchi Map \ No newline at end of file +pack.description=A Resource pack for the Tamagotchi Map + +tama.region.lagoon.name=Lagoon Town +tama.region.farmland.name=Farmland +tama.region.seaside.name=Seaside +tama.region.skyland.name=Skyland \ No newline at end of file