Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c62b3f2
feat: add Understudy error classes to Canopy errors directory
ForestOfLight Jun 23, 2026
2783404
feat: add simplayer utils.js with tests
ForestOfLight Jun 24, 2026
57f1292
feat: add simplayer RepeatableAction and Actions classes
ForestOfLight Jun 24, 2026
7fe4333
feat: add simplayer inventory and player info saver classes
ForestOfLight Jun 24, 2026
6a8d220
feat: add Understudy class with lazy-on hook
ForestOfLight Jun 24, 2026
da61bf9
feat: add Understudies with lazy event/interval management and isUnde…
ForestOfLight Jun 24, 2026
8a1b89e
feat: add simplayer rules using Canopy BooleanRule directly
ForestOfLight Jun 24, 2026
e0be712
feat: add all 15 simplayer commands using canopy:player prefix
ForestOfLight Jun 24, 2026
86c4bcd
feat: wire simplayer commands, rules, and startup hooks into Canopy
ForestOfLight Jun 24, 2026
c635598
fix: correct BooleanRule import path and use getNativeValue() in simp…
ForestOfLight Jun 24, 2026
82b11b5
fix: update noSimplayerSaving mock to use getNativeValue in Understud…
ForestOfLight Jun 24, 2026
b64a4af
test: fix erroring tests
ForestOfLight Jun 24, 2026
647c2da
feat: merge understudy in
ForestOfLight Jun 24, 2026
d0e43ce
feat: reintroduce look rotation command
ForestOfLight Jun 24, 2026
b334b14
feat(simplayer): add en_US translation keys for simplayer commands, m…
ForestOfLight Jun 24, 2026
6e12d59
feat(simplayer): localize shared Understudy/Understudies messages
ForestOfLight Jun 24, 2026
84fb142
feat(simplayer): use translation keys for simplayer rule descriptions
ForestOfLight Jun 24, 2026
a3d371a
feat(simplayer): localize not-online errors for tp/stop/sneak/sprint/…
ForestOfLight Jun 24, 2026
ba77d96
fix: fog layers getting overloaded when left enabled
ForestOfLight Jun 24, 2026
da6f7f7
feat: protect against invalid player actions
ForestOfLight Jun 24, 2026
df507d7
fix: add color clearer to simplayer prefix
ForestOfLight Jun 24, 2026
019c722
fix: remove extra %2 from canopy search results
ForestOfLight Jun 24, 2026
f1563d6
refactor: simplify nametag formatting
ForestOfLight Jun 24, 2026
d7d1d18
feat(simplayer): localize online-state errors for claimprojectiles/sw…
ForestOfLight Jun 24, 2026
2261133
feat(simplayer): localize playerlook messages
ForestOfLight Jun 24, 2026
5ffb921
tests: fix tests from rotation arg addition
ForestOfLight Jun 24, 2026
277748f
feat: noSimplayerSaving -> simplayerSaving
ForestOfLight Jun 24, 2026
8deab71
feat(simplayer): localize playermove messages
ForestOfLight Jun 24, 2026
d96f477
feat(simplayer): localize playeraction messages
ForestOfLight Jun 24, 2026
ca7254b
feat(simplayer): localize playerselect messages
ForestOfLight Jun 24, 2026
d9bdc2c
feat(simplayer): localize playerprefix messages
ForestOfLight Jun 24, 2026
a344ded
feat(simplayer): localize playerinventory messages
ForestOfLight Jun 24, 2026
2408066
fix: simplayer inventory saving not working
ForestOfLight Jun 24, 2026
c60b777
fix: remove extra newlines from simplayer cmd error outputs
ForestOfLight Jun 24, 2026
b540dfb
fix: linting errors
ForestOfLight Jun 25, 2026
e748e84
test(simplayer): align error-path assertions with bare-return contract
ForestOfLight Jun 25, 2026
440b5a4
fix: commit untracked simplayerSaving rule
ForestOfLight Jun 25, 2026
1d62279
feat: remove /playerclaimprojectiles
ForestOfLight Jun 25, 2026
670b1f9
Merge branch 'dev' into simplayers
ForestOfLight Jun 25, 2026
600359c
tests: fix unused imports
ForestOfLight Jun 25, 2026
dea5ef3
tests: remove unused playerclaimprojectiles test
ForestOfLight Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Canopy[BP]/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
"module_name": "@minecraft/debug-utilities",
"version": "1.0.0-beta"
},
{
"module_name": "@minecraft/server-gametest",
"version": "1.0.0-beta"
},
{
"uuid": "bcf34368-ed0c-4cf7-938e-582cccf9950d",
"version": [
Expand Down
36 changes: 22 additions & 14 deletions Canopy[BP]/scripts/lib/SRCItemDatabase/ItemDatabase.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,21 @@ class AsyncQueue {
this.processing = false;
}
enqueue(callback) {
this.queue.push(callback);
if (!this.processing) {
this.dequeue();
}
return new Promise((resolve, reject) => {
this.queue.push(async () => {
try {
const result = await callback();
resolve(result);
return result;
} catch (error) {
reject(error);
throw error;
}
});
if (!this.processing) {
this.dequeue();
}
});
}
async dequeue() {
if (this.processing || this.queue.length === 0) return;
Expand Down Expand Up @@ -78,8 +89,7 @@ class SRCItemDatabase {
async set(key, itemStack) {
if (key.length > 12)
throw new Error(`The provided key "${key}" exceeds the maximum allowed length of 12 characters (actual length: ${key.length}).`);
let success = false;
this.asyncQueue.enqueue(() => {
return this.asyncQueue.enqueue(() => {
const newId = this.table + key, existingStructure = world.structureManager.get(newId), location = SRCItemDatabase.location;
if (existingStructure) {
world.structureManager.delete(newId);
Expand All @@ -98,9 +108,8 @@ class SRCItemDatabase {
const structureIds = Array.from(Databases.structureIds.get(this.table) ?? []);
structureIds.push(newId);
Databases.structureIds.set(this.table, structureIds);
success = true;
return true;
});
return success;
};
setMany(items) { return items.map(item => this.set(item.key, item.item)) };
getAsync(key) {
Expand Down Expand Up @@ -139,7 +148,6 @@ class SRCItemDatabase {
setItems(key, items) {
if (key.length > 12)
throw new Error(`The provided key "${key}" exceeds the maximum allowed length of 12 characters (actual length: ${key.length}).`);
let success = false;
return this.asyncQueue.enqueue(() => {
const newId = this.table + key, existingStructure = world.structureManager.get(newId);
if (existingStructure) {
Expand All @@ -157,21 +165,21 @@ class SRCItemDatabase {
saveMode: this.saveMode
});
itemMemory.set(newId, items);
Databases.structureIds.set(this.table, Array.from(Databases.structureIds.get(this.table) ?? []).push(newId));
success = true;
return success;
const structureIds = Array.from(Databases.structureIds.get(this.table) ?? []).filter(id => id !== newId);
structureIds.push(newId);
Databases.structureIds.set(this.table, structureIds);
return true;
});
}
getItems(key) {
if (key.length > 12)
throw new Error(`The provided key "${key}" exceeds the maximum allowed length of 12 characters (actual length: ${key.length}).`);
const newId = this.table + key, location = SRCItemDatabase.location;
if (!itemMemory.get(newId)) return [];
if (!world.structureManager.get(newId)) return [];
SRCItemDatabase.dimension.getEntities({ type: 'minecraft:item', location, maxDistance: 3 }).forEach(item => item.remove())
world.structureManager.place(newId, SRCItemDatabase.dimension, location, { includeBlocks: false, includeEntities: true });
const items = SRCItemDatabase.dimension.getEntities({ type: 'minecraft:item', location: location, maxDistance: 3 });
if (items.length === 0) return undefined;
if (items.length === 0) return [];
const itemStacksArray = [];
for (const item of items) {
itemStacksArray.push(item.getComponent(EntityItemComponent.componentId).itemStack);
Expand Down
20 changes: 20 additions & 0 deletions Canopy[BP]/scripts/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ import './src/commands/lifetimequery'
import './src/commands/lifetimequeryitem'
import './src/commands/velocity'

// Simulated Player Commands
import './src/commands/simplayer/playerjoin'
import './src/commands/simplayer/playerleave'
import './src/commands/simplayer/playerrejoin'
import './src/commands/simplayer/playertp'
import './src/commands/simplayer/playerlook'
import './src/commands/simplayer/playermove'
import './src/commands/simplayer/playerselect'
import './src/commands/simplayer/playersprint'
import './src/commands/simplayer/playersneak'
import './src/commands/simplayer/playerstop'
import './src/commands/simplayer/playerswapheld'
import './src/commands/simplayer/playerinventory'
import './src/commands/simplayer/playerprefix'
import './src/commands/simplayer/playeraction'

// Script Events
import './src/commands/scriptevents/counter'
import './src/commands/scriptevents/spawn'
Expand Down Expand Up @@ -81,6 +97,10 @@ import './src/rules/entitySeparation'
import './src/rules/enderPearlChunkLoading'
import './src/rules/renderEndGatewayExits'

// Simulated Player Rules
import './src/rules/simplayer/simplayerSaving'
import './src/rules/simplayer/simplayerRejoining'

// Load Time Processes
import './src/onStart'
import './src/onReload'
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class UnderstudyConnectedError extends Error {
constructor(name) {
super(`[Canopy] Simulated player '${name}' is already connected.`);
this.name = 'UnderstudyConnectedError';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class UnderstudyNotConnectedError extends Error {
constructor(name) {
super(`[Canopy] Simulated player '${name}' is not connected.`);
this.name = 'UnderstudyNotConnectedError';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class UnderstudySaveInfoError extends Error {
constructor(message) {
super(message);
this.name = 'UnderstudySaveInfoError';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class UnknownRepeatingActionError extends Error {
constructor(name, type) {
super(`[Canopy] Unknown repeating action '${type}' for simulated player '${name}'.`);
this.name = 'UnknownRepeatingActionError';
}
}
55 changes: 55 additions & 0 deletions Canopy[BP]/scripts/src/classes/simplayer/Actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { system } from "@minecraft/server";
import { RepeatableAction } from "./RepeatableAction";

export class Actions {
#singleActions = [];
#repeatingActions = [];

constructor(understudy) {
this.understudy = understudy;
}

onTick() {
for (const singleAction of this.#singleActions)
singleAction.perform();
this.#singleActions.length = 0;
for (const repeatingAction of this.#repeatingActions)
repeatingAction.onTick();
}

once(type, afterTicks = void 0) {
const repeatableAction = new RepeatableAction(this.understudy, type);
if (afterTicks === void 0)
this.#singleActions.push(repeatableAction);
else
system.runTimeout(() => this.#singleActions.push(repeatableAction), afterTicks);
}

repeat(type, intervalTicks = 0) {
if (this.has(type))
this.remove(type);
const repeatingAction = new RepeatableAction(this.understudy, type, intervalTicks);
this.#repeatingActions.push(repeatingAction);
}

get(type) {
return this.#repeatingActions.find(action => action.type === type);
}

has(type) {
return this.#repeatingActions.some(action => action.type === type);
}

isEmpty() {
return this.#singleActions.length === 0 && this.#repeatingActions.length === 0;
}

remove(type) {
this.#repeatingActions = this.#repeatingActions.filter(action => action.type !== type);
}

clear() {
this.#repeatingActions.length = 0;
this.#singleActions.length = 0;
}
}
94 changes: 94 additions & 0 deletions Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { world, system, DimensionTypes, TicksPerSecond, EntityComponentTypes } from "@minecraft/server";
import { UnderstudyInventorySaver } from "./UnderstudyInventorySaver";
import { simplayerSaving } from "../../rules/simplayer/simplayerSaving";
import { UnderstudySaveInfoError } from "../errors/UnderstudySaveInfoError";
import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError";

export class PlayerInfoSaver {
saveInterval = 600;
#understudy;
#inventory;

constructor(understudy) {
this.#understudy = understudy;
this.#inventory = new UnderstudyInventorySaver(understudy);
}

onConnectedTick() {
this.#saveOnInterval();
}

#saveOnInterval() {
if (!simplayerSaving.getNativeValue())
return;
if ((system.currentTick - this.#understudy.createdTick) % this.saveInterval === 0) {
this.save();
return;
}
if (!this.#understudy.actions.isEmpty()) {
if ((system.currentTick - this.#understudy.createdTick) % (TicksPerSecond * 5) === 0)
this.save();
else
this.#inventory.saveWithoutNBT();
}
}

get() {
if (!simplayerSaving.getNativeValue())
throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has no player info saved due to '${simplayerSaving.getID()}' rule being disabled.`);
let playerInfo;
try {
playerInfo = JSON.parse(world.getDynamicProperty(`${this.#understudy.name}:playerinfo`));
} catch (error) {
if (error.name === 'SyntaxError')
throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has corrupted player info saved, unable to parse player info.`);
throw error;
}
return playerInfo;
}

save() {
if (!simplayerSaving.getNativeValue())
return;
if (!this.#understudy.isConnected())
throw new UnderstudyNotConnectedError();
const simulatedPlayer = this.#understudy.simulatedPlayer;
const playerInfo = {
location: simulatedPlayer.location,
rotation: this.#understudy.headRotation,
dimensionId: simulatedPlayer.dimension.id,
gameMode: simulatedPlayer.getGameMode(),
projectileIds: this.#findOwnedProjectileIds()
};
world.setDynamicProperty(`${this.#understudy.name}:playerinfo`, JSON.stringify(playerInfo));
this.#inventory.save();
}

#findOwnedProjectileIds() {
let projectileIds = [];
for (const dimensionType of DimensionTypes.getAll()) {
const dimension = world.getDimension(dimensionType.typeId);
const projectiles = dimension.getEntities().filter(entity => {
const projectileComponent = entity.getComponent(EntityComponentTypes.Projectile);
return projectileComponent?.owner === this.#understudy.simulatedPlayer;
});
projectileIds = projectileIds.concat(projectiles.map(projectile => projectile.id));
}
return projectileIds;
}

loadInventoryAndProjectileOwnership() {
const playerInfo = this.get();
this.#claimProjectileIds(playerInfo.projectileIds);
this.#inventory.load();
}

#claimProjectileIds(projectileIds) {
projectileIds?.forEach(projectileId => {
const projectile = world.getEntity(projectileId);
const projectileComponent = projectile?.getComponent(EntityComponentTypes.Projectile);
if (projectileComponent)
projectileComponent.owner = this.#understudy.simulatedPlayer;
});
}
}
Loading
Loading