From 9c773f5d44467ad4038d9c2b4a678c70ae5e94a2 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Wed, 22 Jul 2026 09:56:00 +0200 Subject: [PATCH 1/9] feat: add playtime item rewards module --- modules/playtime-item-rewards/module.json | 162 +++++++ .../grant-playtime-item-rewards/index.js | 4 + .../functions/playtime-item-reward-helpers.js | 457 ++++++++++++++++++ .../test/playtime-item-rewards.test.ts | 174 +++++++ 4 files changed, 797 insertions(+) create mode 100644 modules/playtime-item-rewards/module.json create mode 100644 modules/playtime-item-rewards/src/cronjobs/grant-playtime-item-rewards/index.js create mode 100644 modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js create mode 100644 modules/playtime-item-rewards/test/playtime-item-rewards.test.ts diff --git a/modules/playtime-item-rewards/module.json b/modules/playtime-item-rewards/module.json new file mode 100644 index 0000000..003c427 --- /dev/null +++ b/modules/playtime-item-rewards/module.json @@ -0,0 +1,162 @@ +{ + "name": "test-playtime-item-rewards", + "description": "Grant configurable item rewards after accumulated playtime intervals, with optional role-specific profiles.", + "version": "latest", + "supportedGames": [ + "all" + ], + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "playtimeIntervalMinutes": { + "type": "integer", + "minimum": 1, + "default": 120, + "description": "Accumulated playtime required between reward cycles." + }, + "selectionMode": { + "type": "string", + "enum": [ + "single", + "multiple" + ], + "default": "single", + "description": "Grant at most one passing item or every item whose chance passes." + }, + "rewardMessage": { + "type": "string", + "default": "{playerName} received {items} after {intervalMinutes} minutes of playtime.", + "description": "Broadcast after a successful reward. Supports {playerName}, {playerId}, {gameId}, {items}, {itemCount}, {profileName}, and {intervalMinutes}." + }, + "items": { + "type": "array", + "default": [], + "description": "Default item pool.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "\\S", + "description": "Game item identifier accepted by the connector." + }, + "amount": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "quality": { + "type": "string", + "default": "" + }, + "dropChance": { + "type": "number", + "minimum": 0, + "maximum": 100, + "default": 100, + "description": "Independent percentage chance that this item enters the reward selection." + }, + "enabled": { + "type": "boolean", + "default": true + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + }, + "roleOverrides": { + "type": "array", + "default": [], + "description": "Reward profile overrides for Takaro roles, including Discord-synced roles.", + "items": { + "type": "object", + "properties": { + "roleName": { + "type": "string", + "pattern": "\\S" + }, + "priority": { + "type": "integer", + "default": 0 + }, + "playtimeIntervalMinutes": { + "type": "integer", + "minimum": 1 + }, + "selectionMode": { + "type": "string", + "enum": [ + "single", + "multiple" + ] + }, + "rewardMessage": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "\\S" + }, + "amount": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "quality": { + "type": "string", + "default": "" + }, + "dropChance": { + "type": "number", + "minimum": 0, + "maximum": 100, + "default": 100 + }, + "enabled": { + "type": "boolean", + "default": true + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + } + }, + "required": [ + "roleName" + ], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + }, + "uiSchema": {}, + "permissions": [], + "commands": {}, + "hooks": {}, + "cronJobs": { + "grant-playtime-item-rewards": { + "temporalValue": "*/5 * * * *", + "description": "Grant item rewards to online players who reached a new playtime interval.", + "function": "src/cronjobs/grant-playtime-item-rewards/index.js" + } + }, + "functions": { + "playtime-item-reward-helpers": { + "function": "src/functions/playtime-item-reward-helpers.js" + } + } +} diff --git a/modules/playtime-item-rewards/src/cronjobs/grant-playtime-item-rewards/index.js b/modules/playtime-item-rewards/src/cronjobs/grant-playtime-item-rewards/index.js new file mode 100644 index 0000000..87ec62d --- /dev/null +++ b/modules/playtime-item-rewards/src/cronjobs/grant-playtime-item-rewards/index.js @@ -0,0 +1,4 @@ +import { data } from '@takaro/helpers'; +import { processPlaytimeItemRewards } from './playtime-item-reward-helpers.js'; + +await processPlaytimeItemRewards(data.gameServerId, data.module); diff --git a/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js b/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js new file mode 100644 index 0000000..61a9688 --- /dev/null +++ b/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js @@ -0,0 +1,457 @@ +import { takaro } from '@takaro/helpers'; + +const STATE_KEY = 'playtime_item_reward_state'; +const DEFAULT_INTERVAL_MINUTES = 120; +const DEFAULT_REWARD_MESSAGE = '{playerName} received {items} after {intervalMinutes} minutes of playtime.'; +const CLAIM_TIMEOUT_MS = 5 * 60 * 1000; + +function trimOrEmpty(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); +} + +function positiveInteger(value, fallback) { + const parsed = Math.floor(Number(value)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function percentage(value, fallback = 100) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(100, Math.max(0, parsed)); +} + +function getPlaytimeBucket(playtimeSeconds, intervalMinutes) { + const seconds = Math.max(0, Number(playtimeSeconds) || 0); + return Math.floor(seconds / (intervalMinutes * 60)); +} + +function moduleIdFrom(mod) { + return trimOrEmpty(mod?.moduleId || mod?.id); +} + +function normalizeItems(value) { + if (!Array.isArray(value)) return []; + return value + .filter((item) => item && item.enabled !== false) + .map((item) => { + const name = trimOrEmpty(item.name); + if (!name) return null; + return { + name, + amount: positiveInteger(item.amount, 1), + quality: trimOrEmpty(item.quality), + dropChance: percentage(item.dropChance), + }; + }) + .filter(Boolean); +} + +function normalizeSelectionMode(value, fallback = 'single') { + return value === 'multiple' || value === 'single' ? value : fallback; +} + +function defaultProfile(config) { + return { + profileName: 'default', + intervalMinutes: positiveInteger(config?.playtimeIntervalMinutes, DEFAULT_INTERVAL_MINUTES), + selectionMode: normalizeSelectionMode(config?.selectionMode), + rewardMessage: trimOrEmpty(config?.rewardMessage) || DEFAULT_REWARD_MESSAGE, + items: normalizeItems(config?.items), + }; +} + +function profileStateKey(profile) { + return JSON.stringify([profile.profileName, profile.intervalMinutes]); +} + +function activeRoleNames(assignments, gameServerId) { + const now = Date.now(); + return new Set( + (Array.isArray(assignments) ? assignments : []) + .filter((assignment) => { + if (assignment?.gameServerId && assignment.gameServerId !== gameServerId) return false; + if (!assignment?.expiresAt) return true; + const expiresAt = Date.parse(assignment.expiresAt); + return !Number.isFinite(expiresAt) || expiresAt > now; + }) + .map((assignment) => trimOrEmpty(assignment?.role?.name)) + .filter(Boolean), + ); +} + +function resolveProfile(config, assignments, gameServerId) { + const base = defaultProfile(config); + const roleNames = activeRoleNames(assignments, gameServerId); + const matches = (Array.isArray(config?.roleOverrides) ? config.roleOverrides : []) + .map((override, index) => ({ override, index })) + .filter(({ override }) => override && roleNames.has(trimOrEmpty(override.roleName))) + .sort((left, right) => { + const priorityDifference = Number(right.override.priority || 0) - Number(left.override.priority || 0); + return priorityDifference || left.index - right.index; + }); + + if (matches.length === 0) return base; + const override = matches[0].override; + return { + profileName: trimOrEmpty(override.roleName) || base.profileName, + intervalMinutes: override.playtimeIntervalMinutes === undefined + ? base.intervalMinutes + : positiveInteger(override.playtimeIntervalMinutes, base.intervalMinutes), + selectionMode: override.selectionMode === undefined + ? base.selectionMode + : normalizeSelectionMode(override.selectionMode, base.selectionMode), + rewardMessage: override.rewardMessage === undefined + ? base.rewardMessage + : trimOrEmpty(override.rewardMessage), + items: override.items === undefined ? base.items : normalizeItems(override.items), + }; +} + +async function findStateVariable(gameServerId, moduleId, playerId) { + const result = await takaro.variable.variableControllerSearch({ + filters: { + key: [STATE_KEY], + gameServerId: [gameServerId], + moduleId: [moduleId], + playerId: [playerId], + }, + limit: 1, + }); + return result.data.data[0] || null; +} + +function parseState(variable, playerId) { + if (!variable) return { lastConsumedBucket: 0, claim: null }; + try { + const parsed = JSON.parse(variable.value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('state must be a JSON object'); + } + if ( + parsed.consumedBuckets !== undefined + && (!parsed.consumedBuckets || typeof parsed.consumedBuckets !== 'object' || Array.isArray(parsed.consumedBuckets)) + ) { + throw new Error('consumedBuckets must be a JSON object'); + } + return { + ...parsed, + lastConsumedBucket: Math.max(0, Math.floor(Number(parsed.lastConsumedBucket) || 0)), + claim: parsed.claim || null, + }; + } catch (err) { + console.error(`playtime-item-rewards: invalid state for player ${playerId}: ${err}`); + return null; + } +} + +function consumedBucketFor(state, profileKey) { + if (state.consumedBuckets && Object.prototype.hasOwnProperty.call(state.consumedBuckets, profileKey)) { + return Math.max(0, Math.floor(Number(state.consumedBuckets[profileKey]) || 0)); + } + if (!state.lastProfileKey || state.lastProfileKey === profileKey) return state.lastConsumedBucket; + return 0; +} + +async function readState(gameServerId, moduleId, playerId) { + const variable = await findStateVariable(gameServerId, moduleId, playerId); + return { variable, state: parseState(variable, playerId) }; +} + +async function writeState(gameServerId, moduleId, playerId, variable, state) { + const value = JSON.stringify(state); + if (variable) { + await takaro.variable.variableControllerUpdate(variable.id, { value }); + return variable.id; + } + + const created = await takaro.variable.variableControllerCreate({ + key: STATE_KEY, + value, + gameServerId, + moduleId, + playerId, + }); + return created.data.data.id; +} + +function claimIsLive(claim) { + if (!claim?.claimedAt) return false; + if (claim.deliveryStartedAt) return true; + const claimedAt = Date.parse(claim.claimedAt); + return Number.isFinite(claimedAt) && Date.now() - claimedAt < CLAIM_TIMEOUT_MS; +} + +async function acquireBucketClaim(gameServerId, moduleId, playerId, profileKey, bucket) { + const current = await readState(gameServerId, moduleId, playerId); + if (!current.state) { + throw new Error('persisted reward state is corrupt; refusing to grant until it is repaired'); + } + if (bucket <= consumedBucketFor(current.state, profileKey)) return null; + if (current.state.claim && claimIsLive(current.state.claim)) return null; + + const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const claim = { token, profileKey, bucket, claimedAt: new Date().toISOString() }; + await writeState(gameServerId, moduleId, playerId, current.variable, { + ...current.state, + claim, + updatedAt: new Date().toISOString(), + }); + + const confirmed = await readState(gameServerId, moduleId, playerId); + return confirmed.state.claim?.token === token ? claim : null; +} + +async function markDeliveryStarted(gameServerId, moduleId, playerId, claim) { + const current = await readState(gameServerId, moduleId, playerId); + if (!current.state || current.state.claim?.token !== claim.token) return null; + + const deliveryClaim = { + ...current.state.claim, + deliveryStartedAt: new Date().toISOString(), + }; + await writeState(gameServerId, moduleId, playerId, current.variable, { + ...current.state, + claim: deliveryClaim, + updatedAt: new Date().toISOString(), + }); + + const confirmed = await readState(gameServerId, moduleId, playerId); + return confirmed.state?.claim?.token === claim.token + && confirmed.state.claim.deliveryStartedAt === deliveryClaim.deliveryStartedAt + ? deliveryClaim + : null; +} + +async function completeBucket(gameServerId, moduleId, playerId, claim, outcome) { + const current = await readState(gameServerId, moduleId, playerId); + if (!current.state) { + console.error(`playtime-item-rewards: state became corrupt while completing player ${playerId}`); + return false; + } + if (current.state.claim?.token !== claim.token) { + console.error(`playtime-item-rewards: lost bucket claim for player ${playerId}`); + return false; + } + + await writeState(gameServerId, moduleId, playerId, current.variable, { + ...current.state, + consumedBuckets: { + ...(current.state.consumedBuckets || {}), + [claim.profileKey]: claim.bucket, + }, + lastConsumedBucket: claim.bucket, + lastProfileKey: claim.profileKey, + claim: null, + lastOutcome: outcome, + updatedAt: new Date().toISOString(), + }); + return true; +} + +async function releaseClaim(gameServerId, moduleId, playerId, claim, outcome) { + const current = await readState(gameServerId, moduleId, playerId); + if (!current.state) return false; + if (current.state.claim?.token !== claim.token) return false; + await writeState(gameServerId, moduleId, playerId, current.variable, { + ...current.state, + claim: null, + lastOutcome: outcome, + updatedAt: new Date().toISOString(), + }); + return true; +} + +async function playerName(playerId, fallback) { + try { + const result = await takaro.player.playerControllerGetOne(playerId); + return trimOrEmpty(result.data.data.name) || fallback; + } catch (err) { + console.error(`playtime-item-rewards: failed to fetch player ${playerId}: ${err}`); + return fallback; + } +} + +async function findOnlinePlayers(gameServerId) { + const players = []; + const limit = 100; + let page = 0; + + while (page < 100) { + const result = await takaro.playerOnGameserver.playerOnGameServerControllerSearch({ + filters: { gameServerId: [gameServerId], online: [true] }, + limit, + page, + }); + const records = result.data.data; + const hydrated = await Promise.all(records.map(async (record) => ({ + ...record, + name: await playerName(record.playerId, trimOrEmpty(record.gameId) || record.playerId), + }))); + players.push(...hydrated); + if (records.length < limit) break; + page++; + } + + if (page >= 100) console.error('playtime-item-rewards: exceeded online-player pagination cap'); + return players; +} + +function selectItems(profile) { + const passing = profile.items.filter((item) => Math.random() * 100 < item.dropChance); + if (profile.selectionMode === 'multiple') return passing; + return passing.length > 0 ? [passing[Math.floor(Math.random() * passing.length)]] : []; +} + +async function grantItems(gameServerId, player, items) { + const granted = []; + const failed = []; + for (const item of items) { + try { + await takaro.gameserver.gameServerControllerGiveItem(gameServerId, player.playerId, { + name: item.name, + amount: item.amount, + quality: item.quality, + }); + granted.push(item); + } catch (err) { + failed.push(item); + console.error(`playtime-item-rewards: failed to grant ${item.amount}x ${item.name} to ${player.name}: ${err}`); + } + } + return { granted, failed }; +} + +function renderTemplate(template, placeholders) { + return trimOrEmpty(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => ( + Object.prototype.hasOwnProperty.call(placeholders, key) ? String(placeholders[key]) : `{${key}}` + )); +} + +async function announceReward(gameServerId, player, profile, granted) { + if (!profile.rewardMessage) return; + const itemSummary = granted.map((item) => `${item.amount}x ${item.name}`).join(', '); + const message = renderTemplate(profile.rewardMessage, { + playerName: player.name, + playerId: player.playerId, + gameId: player.gameId, + items: itemSummary, + itemCount: granted.reduce((sum, item) => sum + item.amount, 0), + profileName: profile.profileName, + intervalMinutes: profile.intervalMinutes, + }); + if (!message) return; + await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { message, opts: {} }); + console.log(`playtime-item-rewards: announced "${message}"`); +} + +export async function processPlaytimeItemRewards(gameServerId, mod) { + const summary = { + playersChecked: 0, + playersEligible: 0, + rewardsGranted: 0, + noDrop: 0, + failed: 0, + }; + const moduleId = moduleIdFrom(mod); + if (!moduleId) { + console.error('playtime-item-rewards: module id is missing'); + summary.failed++; + return summary; + } + + const config = mod?.userConfig || {}; + const players = await findOnlinePlayers(gameServerId); + summary.playersChecked = players.length; + + for (const player of players) { + let claim = null; + let itemGrantSucceeded = false; + try { + const profile = resolveProfile(config, player.roles, gameServerId); + const profileKey = profileStateKey(profile); + const bucket = getPlaytimeBucket(player.playtimeSeconds, profile.intervalMinutes); + if (bucket < 1) continue; + + if (profile.items.length === 0) { + console.error(`playtime-item-rewards: profile ${profile.profileName} has no valid items for player ${player.name}`); + summary.failed++; + continue; + } + + claim = await acquireBucketClaim(gameServerId, moduleId, player.playerId, profileKey, bucket); + if (!claim) continue; + summary.playersEligible++; + + const selected = selectItems(profile); + if (selected.length === 0) { + const completed = await completeBucket(gameServerId, moduleId, player.playerId, claim, 'no-drop'); + if (!completed) throw new Error('could not persist no-drop bucket completion'); + claim = null; + summary.noDrop++; + console.log(`playtime-item-rewards: no item chance passed for ${player.name} using profile ${profile.profileName}`); + continue; + } + + claim = await markDeliveryStarted(gameServerId, moduleId, player.playerId, claim); + if (!claim) throw new Error('lost bucket claim before item delivery'); + + const result = await grantItems(gameServerId, player, selected); + if (result.granted.length === 0) { + await releaseClaim(gameServerId, moduleId, player.playerId, claim, 'grant-failed'); + claim = null; + summary.failed++; + continue; + } + + itemGrantSucceeded = true; + let stateCompleted = false; + try { + stateCompleted = await completeBucket(gameServerId, moduleId, player.playerId, claim, 'granted'); + } catch (err) { + console.error(`playtime-item-rewards: items granted but bucket completion failed for ${player.name}: ${err}`); + } + if (!stateCompleted) { + summary.failed++; + console.error( + `playtime-item-rewards: retaining claim after successful grant to ${player.name}; manual state repair may be required`, + ); + } else { + claim = null; + } + summary.rewardsGranted += result.granted.length; + if (result.failed.length > 0) summary.failed += result.failed.length; + console.log( + `playtime-item-rewards: granted ${result.granted.map((item) => `${item.amount}x ${item.name}`).join(', ')} to ${player.name} using profile ${profile.profileName}`, + ); + + try { + await announceReward(gameServerId, player, profile, result.granted); + } catch (err) { + console.error(`playtime-item-rewards: items granted but announcement failed for ${player.name}: ${err}`); + } + } catch (err) { + summary.failed++; + console.error(`playtime-item-rewards: failed to process player ${player.playerId}: ${err}`); + if (claim && !itemGrantSucceeded) { + try { + await releaseClaim(gameServerId, moduleId, player.playerId, claim, 'processing-failed'); + } catch (releaseErr) { + console.error(`playtime-item-rewards: failed to release claim for player ${player.playerId}: ${releaseErr}`); + } + } else if (claim) { + console.error( + `playtime-item-rewards: claim retained for player ${player.playerId} because an item was already delivered`, + ); + } + } + } + + if (summary.playersEligible === 0) { + console.log('playtime-item-rewards: no players reached a new playtime interval'); + } + console.log( + `playtime-item-rewards: checked ${summary.playersChecked}, eligible ${summary.playersEligible}, granted ${summary.rewardsGranted}, no-drop ${summary.noDrop}, failed ${summary.failed}`, + ); + return summary; +} diff --git a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts new file mode 100644 index 0000000..e452112 --- /dev/null +++ b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts @@ -0,0 +1,174 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client, EventSearchInputAllowedFiltersEventNameEnum } from '@takaro/apiclient'; +import { createClient } from '../../../test/helpers/client.js'; +import { startMockServer, stopMockServer, MockServerContext } from '../../../test/helpers/mock-server.js'; +import { waitForEvent } from '../../../test/helpers/events.js'; +import { + pushModule, + installModule, + uninstallModule, + deleteModule, + cleanupTestModules, + cleanupTestGameServers, +} from '../../../test/helpers/modules.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MODULE_DIR = path.resolve(__dirname, '..'); + +const DEFAULT_CONFIG = { + playtimeIntervalMinutes: 120, + selectionMode: 'single', + rewardMessage: '{playerName} received {items} after {intervalMinutes} minutes of playtime.', + items: [ + { + name: 'stone', + amount: 1, + quality: '', + dropChance: 100, + enabled: true, + }, + ], + roleOverrides: [], +}; + +type ExecutionMeta = { result?: { success?: boolean; logs?: Array<{ msg: string }> } }; + +type RewardState = { + lastConsumedBucket?: number; + lastOutcome?: string; +}; + +describe('playtime-item-rewards', () => { + let client: Client; + let ctx: MockServerContext | undefined; + let moduleId: string | undefined; + let versionId: string; + let cronjobId: string; + + before(async () => { + client = await createClient(); + await cleanupTestModules(client); + await cleanupTestGameServers(client); + ctx = await startMockServer(client); + + const mod = await pushModule(client, MODULE_DIR); + moduleId = mod.id; + versionId = mod.latestVersion.id; + const cronjob = mod.latestVersion.cronJobs[0]; + assert.equal(mod.latestVersion.cronJobs.length, 1); + assert.equal(cronjob?.name, 'grant-playtime-item-rewards'); + if (!cronjob) throw new Error('Expected grant-playtime-item-rewards cronjob'); + cronjobId = cronjob.id; + }); + + after(async () => { + if (!ctx) return; + if (moduleId) { + try { + await uninstallModule(client, moduleId, ctx.gameServer.id); + } catch (_err) { + // Ignore cleanup races. + } + try { + await deleteModule(client, moduleId); + } catch (err) { + console.error('Cleanup: failed to delete playtime-item-rewards module:', err); + } + } + await stopMockServer(ctx.server, client, ctx.gameServer.id); + }); + + it('pushes and installs the item-only reward module', async () => { + assert.equal( + fs.existsSync(path.join(MODULE_DIR, 'module.json')), + true, + 'Expected the playtime-item-rewards module manifest to exist', + ); + + await installModule(client, versionId, ctx!.gameServer.id, { + userConfig: DEFAULT_CONFIG, + }); + await uninstallModule(client, moduleId!, ctx!.gameServer.id); + }); + + async function triggerCronjob(): Promise<{ success: boolean; logs: string[] }> { + const triggeredAfter = new Date(); + await client.cronjob.cronJobControllerTrigger({ + gameServerId: ctx!.gameServer.id, + cronjobId, + moduleId: moduleId!, + }); + + const event = await waitForEvent(client, { + eventName: EventSearchInputAllowedFiltersEventNameEnum.CronjobExecuted, + gameserverId: ctx!.gameServer.id, + after: triggeredAfter, + timeout: 30000, + }); + + const meta = event.meta as ExecutionMeta; + return { + success: meta.result?.success ?? false, + logs: (meta.result?.logs ?? []).map((entry) => entry.msg), + }; + } + + async function findRewardState(playerId: string): Promise { + const result = await client.variable.variableControllerSearch({ + filters: { + key: ['playtime_item_reward_state'], + gameServerId: [ctx!.gameServer.id], + moduleId: [moduleId!], + playerId: [playerId], + }, + limit: 1, + }); + const variable = result.data.data[0]; + return variable ? JSON.parse(variable.value) as RewardState : null; + } + + async function deleteRewardState(playerId: string): Promise { + const result = await client.variable.variableControllerSearch({ + filters: { + key: ['playtime_item_reward_state'], + gameServerId: [ctx!.gameServer.id], + moduleId: [moduleId!], + playerId: [playerId], + }, + }); + await Promise.all(result.data.data.map((variable) => client.variable.variableControllerDelete(variable.id))); + } + + it('does not create state or grant before the configured interval', async () => { + const playerId = ctx!.players[0].playerId; + await deleteRewardState(playerId); + await installModule(client, versionId, ctx!.gameServer.id, { + userConfig: { + ...DEFAULT_CONFIG, + playtimeIntervalMinutes: 999999, + }, + }); + + try { + const result = await triggerCronjob(); + assert.equal(result.success, true, `Expected cronjob success, logs: ${JSON.stringify(result.logs)}`); + assert.ok( + result.logs.some((msg) => msg.includes('no players reached a new playtime interval')), + `Expected below-threshold log, got: ${JSON.stringify(result.logs)}`, + ); + assert.equal(await findRewardState(playerId), null); + } finally { + await uninstallModule(client, moduleId!, ctx!.gameServer.id); + } + }); + + it.skip('consumes an eligible bucket once and skips a second run in the same bucket (Paper live test)', () => { + // @takaro/mock-gameserver does not expose or advance playtimeSeconds. This exact + // positive path is exercised against Paper in the mandatory live verification. + }); +}); From d16afa35602d6a971d978febe36d1dd511c6b0a1 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Wed, 22 Jul 2026 10:13:26 +0200 Subject: [PATCH 2/9] fix: canonicalize playtime rewards module name --- modules/playtime-item-rewards/module.json | 4 ++-- .../test/playtime-item-rewards.test.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/playtime-item-rewards/module.json b/modules/playtime-item-rewards/module.json index 003c427..da95ec8 100644 --- a/modules/playtime-item-rewards/module.json +++ b/modules/playtime-item-rewards/module.json @@ -1,6 +1,6 @@ { - "name": "test-playtime-item-rewards", - "description": "Grant configurable item rewards after accumulated playtime intervals, with optional role-specific profiles.", + "name": "playtime-item-rewards", + "description": "Grant configurable item-only rewards after accumulated playtime intervals, with independent drop chances, success-only announcements, and role-specific reward profiles.", "version": "latest", "supportedGames": [ "all" diff --git a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts index e452112..05c584e 100644 --- a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +++ b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts @@ -84,11 +84,11 @@ describe('playtime-item-rewards', () => { }); it('pushes and installs the item-only reward module', async () => { - assert.equal( - fs.existsSync(path.join(MODULE_DIR, 'module.json')), - true, - 'Expected the playtime-item-rewards module manifest to exist', - ); + const manifestPath = path.join(MODULE_DIR, 'module.json'); + assert.equal(fs.existsSync(manifestPath), true, 'Expected the playtime-item-rewards module manifest to exist'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: string; description?: string }; + assert.equal(manifest.name, 'playtime-item-rewards'); + assert.match(manifest.description ?? '', /item(?:-only)? rewards/i); await installModule(client, versionId, ctx!.gameServer.id, { userConfig: DEFAULT_CONFIG, From c0758aee314df7ad4ac61751b8c8f3b1465a6330 Mon Sep 17 00:00:00 2001 From: "koji-assistant[bot]" Date: Wed, 22 Jul 2026 17:47:52 +0000 Subject: [PATCH 3/9] feat(playtime-item-rewards): add private reward message delivery --- modules/playtime-item-rewards/module.json | 22 +++++++++++++++- .../functions/playtime-item-reward-helpers.js | 25 +++++++++++++++++-- .../test/playtime-item-rewards.test.ts | 9 ++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/modules/playtime-item-rewards/module.json b/modules/playtime-item-rewards/module.json index da95ec8..0108745 100644 --- a/modules/playtime-item-rewards/module.json +++ b/modules/playtime-item-rewards/module.json @@ -27,7 +27,18 @@ "rewardMessage": { "type": "string", "default": "{playerName} received {items} after {intervalMinutes} minutes of playtime.", - "description": "Broadcast after a successful reward. Supports {playerName}, {playerId}, {gameId}, {items}, {itemCount}, {profileName}, and {intervalMinutes}." + "description": "Message after a successful reward. Supports {playerName}, {playerId}, {gameId}, {items}, {itemCount}, {profileName}, and {intervalMinutes}." + }, + "messageDelivery": { + "type": "string", + "enum": [ + "broadcast", + "private", + "both", + "off" + ], + "default": "broadcast", + "description": "Where successful reward messages are sent: broadcast to the server, private message only to the rewarded player, both, or off." }, "items": { "type": "array", @@ -97,6 +108,15 @@ "rewardMessage": { "type": "string" }, + "messageDelivery": { + "type": "string", + "enum": [ + "broadcast", + "private", + "both", + "off" + ] + }, "items": { "type": "array", "items": { diff --git a/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js b/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js index 61a9688..a0a5c29 100644 --- a/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js +++ b/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js @@ -51,12 +51,17 @@ function normalizeSelectionMode(value, fallback = 'single') { return value === 'multiple' || value === 'single' ? value : fallback; } +function normalizeMessageDelivery(value, fallback = 'broadcast') { + return ['broadcast', 'private', 'both', 'off'].includes(value) ? value : fallback; +} + function defaultProfile(config) { return { profileName: 'default', intervalMinutes: positiveInteger(config?.playtimeIntervalMinutes, DEFAULT_INTERVAL_MINUTES), selectionMode: normalizeSelectionMode(config?.selectionMode), rewardMessage: trimOrEmpty(config?.rewardMessage) || DEFAULT_REWARD_MESSAGE, + messageDelivery: normalizeMessageDelivery(config?.messageDelivery), items: normalizeItems(config?.items), }; } @@ -104,6 +109,9 @@ function resolveProfile(config, assignments, gameServerId) { rewardMessage: override.rewardMessage === undefined ? base.rewardMessage : trimOrEmpty(override.rewardMessage), + messageDelivery: override.messageDelivery === undefined + ? base.messageDelivery + : normalizeMessageDelivery(override.messageDelivery, base.messageDelivery), items: override.items === undefined ? base.items : normalizeItems(override.items), }; } @@ -329,6 +337,8 @@ function renderTemplate(template, placeholders) { } async function announceReward(gameServerId, player, profile, granted) { + const delivery = normalizeMessageDelivery(profile.messageDelivery); + if (delivery === 'off') return; if (!profile.rewardMessage) return; const itemSummary = granted.map((item) => `${item.amount}x ${item.name}`).join(', '); const message = renderTemplate(profile.rewardMessage, { @@ -341,8 +351,19 @@ async function announceReward(gameServerId, player, profile, granted) { intervalMinutes: profile.intervalMinutes, }); if (!message) return; - await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { message, opts: {} }); - console.log(`playtime-item-rewards: announced "${message}"`); + + if (delivery === 'broadcast' || delivery === 'both') { + await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { message, opts: {} }); + } + + if ((delivery === 'private' || delivery === 'both') && trimOrEmpty(player.gameId)) { + await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { + message, + opts: { recipient: { gameId: player.gameId } }, + }); + } + + console.log(`playtime-item-rewards: sent ${delivery} reward message "${message}"`); } export async function processPlaytimeItemRewards(gameServerId, mod) { diff --git a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts index 05c584e..3768dab 100644 --- a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +++ b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts @@ -24,6 +24,7 @@ const DEFAULT_CONFIG = { playtimeIntervalMinutes: 120, selectionMode: 'single', rewardMessage: '{playerName} received {items} after {intervalMinutes} minutes of playtime.', + messageDelivery: 'broadcast', items: [ { name: 'stone', @@ -86,9 +87,15 @@ describe('playtime-item-rewards', () => { it('pushes and installs the item-only reward module', async () => { const manifestPath = path.join(MODULE_DIR, 'module.json'); assert.equal(fs.existsSync(manifestPath), true, 'Expected the playtime-item-rewards module manifest to exist'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: string; description?: string }; + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + name?: string; + description?: string; + config?: { properties?: Record }; + }; assert.equal(manifest.name, 'playtime-item-rewards'); assert.match(manifest.description ?? '', /item(?:-only)? rewards/i); + assert.deepEqual(manifest.config?.properties?.messageDelivery?.enum, ['broadcast', 'private', 'both', 'off']); + assert.equal(manifest.config?.properties?.messageDelivery?.default, 'broadcast'); await installModule(client, versionId, ctx!.gameServer.id, { userConfig: DEFAULT_CONFIG, From 1691affa6bf82231ecfedc54fb8f2f7621884de4 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 20:59:03 +0200 Subject: [PATCH 4/9] docs: design persistent random playtime intervals --- ...-07-23-playtime-random-intervals-design.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/plans/2026-07-23-playtime-random-intervals-design.md diff --git a/docs/plans/2026-07-23-playtime-random-intervals-design.md b/docs/plans/2026-07-23-playtime-random-intervals-design.md new file mode 100644 index 0000000..6b1e2d5 --- /dev/null +++ b/docs/plans/2026-07-23-playtime-random-intervals-design.md @@ -0,0 +1,79 @@ +# Playtime Item Rewards: Persistent Random Intervals + +## Goal + +Let server administrators configure either a fixed playtime reward interval or a +random interval range. Random intervals are selected independently for each +player and reward cycle, remain stable across cron runs and offline sessions, +and are replaced only after the current cycle is consumed. + +## User configuration + +The existing `playtimeIntervalMinutes` field remains the minimum interval and +continues to act as the fixed interval when no maximum is configured. Add an +optional `playtimeIntervalMaximumMinutes` integer beside it. Its dashboard +description must state that leaving it empty, or setting it equal to the +minimum, produces a fixed interval. + +Role overrides receive the same optional maximum field. A role override that +sets neither interval field inherits the complete default range. If it sets a +minimum but no maximum, it is a fixed interval. If it sets only a maximum, it +uses the default minimum with that maximum. At runtime, a maximum below the +minimum is clamped to the minimum so an invalid range cannot destabilize the +cronjob. + +The existing `messageDelivery` user setting remains editable and unchanged. + +## Scheduling and persistence + +The module stores a per-player schedule for each effective reward profile. A +profile key includes the profile name, minimum interval, and maximum interval, +so switching roles or changing range configuration does not reuse an +incompatible schedule. + +Each schedule records: + +- the randomly selected interval in minutes; +- the accumulated-playtime threshold in seconds at which the reward becomes + eligible. + +When no schedule exists, the module chooses an inclusive random integer between +the configured minimum and maximum and stores the resulting threshold. Repeated +cron runs reuse this stored threshold. Going offline does not modify it. + +After a successful grant or a completed no-drop outcome, the next interval is +chosen and the next threshold is based on the player's current accumulated +playtime. Failed item delivery does not advance the schedule, so the eligible +cycle can retry. + +For legacy fixed-interval state, the first schedule is derived from the last +consumed bucket. This prevents an upgrade from immediately duplicating a reward +that was already consumed. + +## Messages and state safety + +`{intervalMinutes}` renders the interval selected for the completed cycle, not +the minimum bound. Private, broadcast, both, and off delivery modes continue to +work as before. + +The existing claim lifecycle remains in place. A claim records the profile key, +selected interval, and eligible threshold. Completion verifies the same claim +before advancing the persisted schedule. Ambiguous post-delivery failures keep +the claim locked to avoid duplicate item grants. + +## Verification + +Automated tests must use the real Takaro API. They will verify that: + +- the new default and role-override fields survive module import and install; +- a range creates a selected interval within its inclusive bounds; +- a second cron execution before eligibility reuses the same stored interval; +- a maximum below the minimum is normalized to a fixed minimum interval; +- the existing fixed interval, message delivery, and below-threshold behavior + remain compatible. + +Mandatory Paper verification will use a short range, disconnect and reconnect a +bot, trigger the cronjob, and inspect the Takaro execution event, bot inventory, +private message, and persisted next schedule. The final source export will then +replace the community catalog JSON so administrators can edit the new maximum +field in the dashboard. From 69366b6fce90eb246fb932aac27ceeff1fea9f39 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:00:26 +0200 Subject: [PATCH 5/9] docs: plan persistent random playtime intervals --- .../2026-07-23-playtime-random-intervals.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/plans/2026-07-23-playtime-random-intervals.md diff --git a/docs/plans/2026-07-23-playtime-random-intervals.md b/docs/plans/2026-07-23-playtime-random-intervals.md new file mode 100644 index 0000000..c4ce910 --- /dev/null +++ b/docs/plans/2026-07-23-playtime-random-intervals.md @@ -0,0 +1,282 @@ +# Persistent Random Playtime Intervals Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add clear user-configurable minimum/maximum playtime intervals whose randomly selected per-player cycle survives cron reruns and offline sessions. + +**Architecture:** Preserve the existing fixed `playtimeIntervalMinutes` behavior when no maximum is configured. Store a per-profile schedule containing the chosen interval and accumulated-playtime threshold; advance that schedule only after a completed grant or no-drop cycle, while retaining the existing claim safety model. + +**Tech Stack:** Takaro module JSON schema, server-side JavaScript with `@takaro/helpers`, TypeScript integration tests using the real Takaro API, Minecraft Paper bot verification, community module JSON export. + +--- + +### Task 1: Expose a clear editable maximum interval + +**Files:** +- Modify: `modules/playtime-item-rewards/test/playtime-item-rewards.test.ts:23-104` +- Modify: `modules/playtime-item-rewards/module.json:12-17` +- Modify: `modules/playtime-item-rewards/module.json:97-100` + +**Step 1: Write the failing schema test** + +Extend the real import/install test to require the default and role-override +`playtimeIntervalMaximumMinutes` schemas and explicit descriptions: + +```ts +type ConfigProperty = { + type?: string; + minimum?: number; + default?: number | string; + description?: string; + items?: { properties?: Record }; +}; + +const properties = manifest.config?.properties ?? {}; +assert.equal(properties.playtimeIntervalMaximumMinutes?.type, 'integer'); +assert.equal(properties.playtimeIntervalMaximumMinutes?.minimum, 1); +assert.match(properties.playtimeIntervalMaximumMinutes?.description ?? '', /leave.*empty.*fixed/i); +assert.equal( + properties.roleOverrides?.items?.properties?.playtimeIntervalMaximumMinutes?.type, + 'integer', +); +``` + +**Step 2: Run the focused test and verify RED** + +Run: + +```bash +node --test-force-exit --test-concurrency 1 --import=ts-node-maintained/register/esm \ + --test-name-pattern='pushes and installs' \ + modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +``` + +Expected: FAIL because `playtimeIntervalMaximumMinutes` is absent. + +**Step 3: Add the minimum/maximum schemas** + +Keep `playtimeIntervalMinutes` as the minimum/fixed interval and make its +description explicit. Add this adjacent field to the default config and role +override config: + +```json +"playtimeIntervalMaximumMinutes": { + "type": "integer", + "minimum": 1, + "description": "Optional maximum interval. Leave empty or set equal to playtimeIntervalMinutes for a fixed interval." +} +``` + +**Step 4: Run the focused test and verify GREEN** + +Run the Step 2 command again. Expected: the selected test passes. + +**Step 5: Commit** + +```bash +git add modules/playtime-item-rewards/module.json \ + modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +git commit -m "feat(playtime-item-rewards): expose interval range config" +``` + +### Task 2: Persist one selected interval per player cycle + +**Files:** +- Modify: `modules/playtime-item-rewards/test/playtime-item-rewards.test.ts:42-180` +- Modify: `modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js:13-270` +- Modify: `modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js:339-453` + +**Step 1: Write the failing real-API persistence test** + +Extend `RewardState` with schedules and replace the old no-state assertion with +a range test using an unreachable range on the real mock game server: + +```ts +type RewardSchedule = { + intervalMinutes: number; + eligibleAtPlaytimeSeconds: number; +}; + +type RewardState = { + schedules?: Record; + claim?: unknown; + lastOutcome?: string; +}; + +it('selects and persists one interval for the current player cycle', async () => { + // Install with min 120 and max 300, trigger twice, and read the real Takaro + // variable after each run. + // Assert one schedule exists, interval is 120..300 inclusive, threshold is + // interval * 60, and both persisted schedule snapshots are deeply equal. +}); +``` + +Add a second real-API test with minimum `300` and maximum `120`; assert the +stored interval and threshold normalize to `300` and `18000`. + +**Step 2: Run the new tests and verify RED** + +Run: + +```bash +node --test-force-exit --test-concurrency 1 --import=ts-node-maintained/register/esm \ + --test-name-pattern='selects and persists|normalizes' \ + modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +``` + +Expected: FAIL because no schedule is persisted before eligibility. + +**Step 3: Implement range normalization and schedule persistence** + +Add profile fields `intervalMinimumMinutes` and `intervalMaximumMinutes`, where +the maximum defaults to the minimum and is clamped upward. Include both bounds +in `profileStateKey`. + +Add helpers with this behavior: + +```js +function randomIntegerInclusive(minimum, maximum) { + if (maximum <= minimum) return minimum; + return minimum + Math.floor(Math.random() * (maximum - minimum + 1)); +} + +function createSchedule(profile, cycleStartPlaytimeSeconds) { + const intervalMinutes = randomIntegerInclusive( + profile.intervalMinimumMinutes, + profile.intervalMaximumMinutes, + ); + return { + intervalMinutes, + eligibleAtPlaytimeSeconds: cycleStartPlaytimeSeconds + intervalMinutes * 60, + }; +} +``` + +Validate `state.schedules` in `parseState`. On each player scan, create and +persist a missing schedule before checking eligibility. Re-read the variable +after writing and use the stored schedule, ensuring overlapping cron runs do not +silently reroll the cycle. + +For legacy state, derive the first fixed schedule start from +`consumedBucketFor(state, legacyProfileKey) * intervalMinutes * 60`. + +**Step 4: Generalize claims and cycle completion** + +Replace bucket-only claim fields with `profileKey`, `intervalMinutes`, and +`eligibleAtPlaytimeSeconds`. Completion must preserve legacy consumed-bucket +fields, clear the claim, and store the next schedule based on the current +accumulated playtime. No-drop completion advances the schedule; grant failure +releases the claim without advancing it. + +Pass `claim.intervalMinutes` into the reward message renderer so +`{intervalMinutes}` displays the selected cycle interval. + +**Step 5: Run the focused tests and verify GREEN** + +Run the Step 2 command. Expected: both selected tests pass using real Takaro +variables and cron executions. + +**Step 6: Run the complete module test** + +```bash +npm run build +npm run typecheck +node --check modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js +node --test-force-exit --test-concurrency 1 --import=ts-node-maintained/register/esm \ + modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +``` + +Expected: all non-Paper tests pass; the documented Paper-only case remains +skipped. + +**Step 7: Commit** + +```bash +git add modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js \ + modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +git commit -m "feat(playtime-item-rewards): persist random reward intervals" +``` + +### Task 3: Verify the player experience on Paper + +**Files:** +- Modify: `docs/verification/2026-07-23-playtime-item-rewards-random-intervals.md` + +**Step 1: Export and import a uniquely named live-test copy** + +Build the exact branch source, copy it into a temporary detached worktree, and +change only the temporary manifest name to avoid replacing a shared module. +Configure minimum `1`, maximum `2`, `messageDelivery: private`, and a 100% +one-stone item. + +**Step 2: Exercise reconnect behavior** + +Connect `Bot_priv23`, confirm its accumulated playtime still exceeds one minute, +trigger the cronjob, and capture the `cronjob-executed` event. Verify: + +- `success: true`; +- one item grant succeeds; +- the bot receives the private message; +- the completed message renders the selected `intervalMinutes`; +- persisted state contains a next schedule with interval `1` or `2`; +- disconnect/reconnect does not replace that next schedule. + +**Step 3: Verify duplicate prevention** + +Trigger the cronjob again before the next threshold. Verify no second item or +message is delivered and the stored schedule remains unchanged. + +**Step 4: Clean up and document evidence** + +Remove the temporary installation, module, bot, and detached worktree. Record +IDs, config, event results, schedule state, and cleanup confirmation in the +verification document without storing credentials. + +**Step 5: Commit** + +```bash +git add docs/verification/2026-07-23-playtime-item-rewards-random-intervals.md +git commit -m "test(playtime-item-rewards): verify random intervals live" +``` + +### Task 4: Publish the updated user-configurable module + +**Files:** +- Regenerate: `/home/hendrik/community-modules-viewer/public/modules/economy/playtime-item-rewards.json` + +**Step 1: Export the exact source branch** + +```bash +npm run build +node dist/scripts/module-to-json.js modules/playtime-item-rewards \ + /tmp/playtime-item-rewards.json +``` + +Verify the export name, description, maximum interval schemas, message delivery +schema, helper code, and cronjob. + +**Step 2: Push the source branch** + +```bash +git push origin feat/playtime-item-rewards +``` + +Confirm PR #60 points at the new head and build/typecheck completes. + +**Step 3: Update the viewer in an isolated branch** + +Create a viewer worktree from current `origin/main`, replace only +`public/modules/economy/playtime-item-rewards.json` with the source export, and +verify filename/name equality plus a non-empty description. + +**Step 4: Verify the viewer** + +Run the targeted module-loader test, formatting check, typecheck, and production +build using the viewer repository's discovered scripts. Expected: all scoped +checks pass, with any unrelated baseline failures reported separately. + +**Step 5: Commit, push, and create the catalog PR** + +Use the `create-pr` skill. The PR description must explain the new editable +maximum field, persistent per-player cycle selection, offline behavior, private +message support, source PR relationship, and live Paper evidence. From 704abd8a6547047187f48c9cdadf70f4924affe4 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:03:58 +0200 Subject: [PATCH 6/9] feat(playtime-item-rewards): expose interval range config --- modules/playtime-item-rewards/module.json | 15 +++++++++++-- .../test/playtime-item-rewards.test.ts | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/modules/playtime-item-rewards/module.json b/modules/playtime-item-rewards/module.json index 0108745..d96f499 100644 --- a/modules/playtime-item-rewards/module.json +++ b/modules/playtime-item-rewards/module.json @@ -13,7 +13,12 @@ "type": "integer", "minimum": 1, "default": 120, - "description": "Accumulated playtime required between reward cycles." + "description": "Minimum reward interval in accumulated playtime minutes. This is also the fixed interval when playtimeIntervalMaximumMinutes is empty." + }, + "playtimeIntervalMaximumMinutes": { + "type": "integer", + "minimum": 1, + "description": "Optional maximum reward interval in accumulated playtime minutes. Leave empty or set equal to playtimeIntervalMinutes for a fixed interval; set it higher to choose a random interval per player and reward cycle." }, "selectionMode": { "type": "string", @@ -96,7 +101,13 @@ }, "playtimeIntervalMinutes": { "type": "integer", - "minimum": 1 + "minimum": 1, + "description": "Optional role-specific minimum interval. Without a role-specific maximum, this is a fixed interval." + }, + "playtimeIntervalMaximumMinutes": { + "type": "integer", + "minimum": 1, + "description": "Optional role-specific maximum interval. Leave empty for a fixed role interval, or set it higher than the effective minimum for a random interval." }, "selectionMode": { "type": "string", diff --git a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts index 3768dab..3e5a7ef 100644 --- a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +++ b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts @@ -39,6 +39,15 @@ const DEFAULT_CONFIG = { type ExecutionMeta = { result?: { success?: boolean; logs?: Array<{ msg: string }> } }; +type ConfigProperty = { + type?: string; + minimum?: number; + default?: number | string; + description?: string; + enum?: string[]; + items?: { properties?: Record }; +}; + type RewardState = { lastConsumedBucket?: number; lastOutcome?: string; @@ -90,12 +99,22 @@ describe('playtime-item-rewards', () => { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: string; description?: string; - config?: { properties?: Record }; + config?: { properties?: Record }; }; assert.equal(manifest.name, 'playtime-item-rewards'); assert.match(manifest.description ?? '', /item(?:-only)? rewards/i); assert.deepEqual(manifest.config?.properties?.messageDelivery?.enum, ['broadcast', 'private', 'both', 'off']); assert.equal(manifest.config?.properties?.messageDelivery?.default, 'broadcast'); + assert.equal(manifest.config?.properties?.playtimeIntervalMaximumMinutes?.type, 'integer'); + assert.equal(manifest.config?.properties?.playtimeIntervalMaximumMinutes?.minimum, 1); + assert.match( + manifest.config?.properties?.playtimeIntervalMaximumMinutes?.description ?? '', + /leave.*empty.*fixed/i, + ); + assert.equal( + manifest.config?.properties?.roleOverrides?.items?.properties?.playtimeIntervalMaximumMinutes?.type, + 'integer', + ); await installModule(client, versionId, ctx!.gameServer.id, { userConfig: DEFAULT_CONFIG, From f761b061ffa91bd0c0af0c448025d8770b4c2ed8 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:09:25 +0200 Subject: [PATCH 7/9] feat(playtime-item-rewards): persist random reward intervals --- .../functions/playtime-item-reward-helpers.js | 194 +++++++++++++++--- .../test/playtime-item-rewards.test.ts | 59 +++++- 2 files changed, 220 insertions(+), 33 deletions(-) diff --git a/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js b/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js index a0a5c29..f92d425 100644 --- a/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js +++ b/modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js @@ -26,6 +26,11 @@ function getPlaytimeBucket(playtimeSeconds, intervalMinutes) { return Math.floor(seconds / (intervalMinutes * 60)); } +function randomIntegerInclusive(minimum, maximum) { + if (maximum <= minimum) return minimum; + return minimum + Math.floor(Math.random() * (maximum - minimum + 1)); +} + function moduleIdFrom(mod) { return trimOrEmpty(mod?.moduleId || mod?.id); } @@ -56,9 +61,14 @@ function normalizeMessageDelivery(value, fallback = 'broadcast') { } function defaultProfile(config) { + const intervalMinimumMinutes = positiveInteger(config?.playtimeIntervalMinutes, DEFAULT_INTERVAL_MINUTES); + const configuredMaximum = config?.playtimeIntervalMaximumMinutes === undefined + ? intervalMinimumMinutes + : positiveInteger(config.playtimeIntervalMaximumMinutes, intervalMinimumMinutes); return { profileName: 'default', - intervalMinutes: positiveInteger(config?.playtimeIntervalMinutes, DEFAULT_INTERVAL_MINUTES), + intervalMinimumMinutes, + intervalMaximumMinutes: Math.max(intervalMinimumMinutes, configuredMaximum), selectionMode: normalizeSelectionMode(config?.selectionMode), rewardMessage: trimOrEmpty(config?.rewardMessage) || DEFAULT_REWARD_MESSAGE, messageDelivery: normalizeMessageDelivery(config?.messageDelivery), @@ -67,7 +77,15 @@ function defaultProfile(config) { } function profileStateKey(profile) { - return JSON.stringify([profile.profileName, profile.intervalMinutes]); + return JSON.stringify([ + profile.profileName, + profile.intervalMinimumMinutes, + profile.intervalMaximumMinutes, + ]); +} + +function legacyProfileStateKey(profile) { + return JSON.stringify([profile.profileName, profile.intervalMinimumMinutes]); } function activeRoleNames(assignments, gameServerId) { @@ -98,11 +116,24 @@ function resolveProfile(config, assignments, gameServerId) { if (matches.length === 0) return base; const override = matches[0].override; + const intervalMinimumMinutes = override.playtimeIntervalMinutes === undefined + ? base.intervalMinimumMinutes + : positiveInteger(override.playtimeIntervalMinutes, base.intervalMinimumMinutes); + let intervalMaximumMinutes; + if (override.playtimeIntervalMaximumMinutes !== undefined) { + intervalMaximumMinutes = positiveInteger( + override.playtimeIntervalMaximumMinutes, + intervalMinimumMinutes, + ); + } else if (override.playtimeIntervalMinutes !== undefined) { + intervalMaximumMinutes = intervalMinimumMinutes; + } else { + intervalMaximumMinutes = base.intervalMaximumMinutes; + } return { profileName: trimOrEmpty(override.roleName) || base.profileName, - intervalMinutes: override.playtimeIntervalMinutes === undefined - ? base.intervalMinutes - : positiveInteger(override.playtimeIntervalMinutes, base.intervalMinutes), + intervalMinimumMinutes, + intervalMaximumMinutes: Math.max(intervalMinimumMinutes, intervalMaximumMinutes), selectionMode: override.selectionMode === undefined ? base.selectionMode : normalizeSelectionMode(override.selectionMode, base.selectionMode), @@ -142,6 +173,23 @@ function parseState(variable, playerId) { ) { throw new Error('consumedBuckets must be a JSON object'); } + if ( + parsed.schedules !== undefined + && (!parsed.schedules || typeof parsed.schedules !== 'object' || Array.isArray(parsed.schedules)) + ) { + throw new Error('schedules must be a JSON object'); + } + for (const schedule of Object.values(parsed.schedules || {})) { + if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule)) { + throw new Error('each schedule must be a JSON object'); + } + if (!Number.isInteger(schedule.intervalMinutes) || schedule.intervalMinutes < 1) { + throw new Error('schedule intervalMinutes must be a positive integer'); + } + if (!Number.isFinite(schedule.eligibleAtPlaytimeSeconds) || schedule.eligibleAtPlaytimeSeconds < 0) { + throw new Error('schedule eligibleAtPlaytimeSeconds must be a non-negative number'); + } + } return { ...parsed, lastConsumedBucket: Math.max(0, Math.floor(Number(parsed.lastConsumedBucket) || 0)), @@ -161,6 +209,23 @@ function consumedBucketFor(state, profileKey) { return 0; } +function createSchedule(profile, cycleStartPlaytimeSeconds) { + const intervalMinutes = randomIntegerInclusive( + profile.intervalMinimumMinutes, + profile.intervalMaximumMinutes, + ); + const cycleStart = Math.max(0, Number(cycleStartPlaytimeSeconds) || 0); + return { + intervalMinutes, + eligibleAtPlaytimeSeconds: cycleStart + intervalMinutes * 60, + }; +} + +function schedulesMatch(left, right) { + return left?.intervalMinutes === right?.intervalMinutes + && left?.eligibleAtPlaytimeSeconds === right?.eligibleAtPlaytimeSeconds; +} + async function readState(gameServerId, moduleId, playerId) { const variable = await findStateVariable(gameServerId, moduleId, playerId); return { variable, state: parseState(variable, playerId) }; @@ -183,6 +248,37 @@ async function writeState(gameServerId, moduleId, playerId, variable, state) { return created.data.data.id; } +async function ensureSchedule(gameServerId, moduleId, playerId, profile) { + const profileKey = profileStateKey(profile); + const current = await readState(gameServerId, moduleId, playerId); + if (!current.state) { + throw new Error('persisted reward state is corrupt; refusing to schedule until it is repaired'); + } + + const existing = current.state.schedules?.[profileKey]; + if (existing) return { profileKey, schedule: existing }; + + const legacyKey = legacyProfileStateKey(profile); + const legacyBucket = consumedBucketFor(current.state, legacyKey); + const cycleStartPlaytimeSeconds = legacyBucket * profile.intervalMinimumMinutes * 60; + const schedule = createSchedule(profile, cycleStartPlaytimeSeconds); + await writeState(gameServerId, moduleId, playerId, current.variable, { + ...current.state, + schedules: { + ...(current.state.schedules || {}), + [profileKey]: schedule, + }, + updatedAt: new Date().toISOString(), + }); + + const confirmed = await readState(gameServerId, moduleId, playerId); + const confirmedSchedule = confirmed.state?.schedules?.[profileKey]; + if (!confirmed.state || !confirmedSchedule) { + throw new Error('could not persist reward schedule'); + } + return { profileKey, schedule: confirmedSchedule }; +} + function claimIsLive(claim) { if (!claim?.claimedAt) return false; if (claim.deliveryStartedAt) return true; @@ -190,16 +286,24 @@ function claimIsLive(claim) { return Number.isFinite(claimedAt) && Date.now() - claimedAt < CLAIM_TIMEOUT_MS; } -async function acquireBucketClaim(gameServerId, moduleId, playerId, profileKey, bucket) { +async function acquireScheduleClaim(gameServerId, moduleId, playerId, profileKey, schedule, playtimeSeconds) { const current = await readState(gameServerId, moduleId, playerId); if (!current.state) { throw new Error('persisted reward state is corrupt; refusing to grant until it is repaired'); } - if (bucket <= consumedBucketFor(current.state, profileKey)) return null; + const currentSchedule = current.state.schedules?.[profileKey]; + if (!schedulesMatch(currentSchedule, schedule)) return null; + if (playtimeSeconds < currentSchedule.eligibleAtPlaytimeSeconds) return null; if (current.state.claim && claimIsLive(current.state.claim)) return null; const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - const claim = { token, profileKey, bucket, claimedAt: new Date().toISOString() }; + const claim = { + token, + profileKey, + intervalMinutes: currentSchedule.intervalMinutes, + eligibleAtPlaytimeSeconds: currentSchedule.eligibleAtPlaytimeSeconds, + claimedAt: new Date().toISOString(), + }; await writeState(gameServerId, moduleId, playerId, current.variable, { ...current.state, claim, @@ -231,25 +335,37 @@ async function markDeliveryStarted(gameServerId, moduleId, playerId, claim) { : null; } -async function completeBucket(gameServerId, moduleId, playerId, claim, outcome) { +async function completeCycle(gameServerId, moduleId, playerId, claim, outcome, playtimeSeconds, profile) { const current = await readState(gameServerId, moduleId, playerId); if (!current.state) { console.error(`playtime-item-rewards: state became corrupt while completing player ${playerId}`); return false; } if (current.state.claim?.token !== claim.token) { - console.error(`playtime-item-rewards: lost bucket claim for player ${playerId}`); + console.error(`playtime-item-rewards: lost cycle claim for player ${playerId}`); + return false; + } + const currentSchedule = current.state.schedules?.[claim.profileKey]; + if (!schedulesMatch(currentSchedule, claim)) { + console.error(`playtime-item-rewards: reward schedule changed while completing player ${playerId}`); return false; } + const legacyKey = legacyProfileStateKey(profile); + const consumedBucket = getPlaytimeBucket(playtimeSeconds, profile.intervalMinimumMinutes); + const nextSchedule = createSchedule(profile, playtimeSeconds); await writeState(gameServerId, moduleId, playerId, current.variable, { ...current.state, + schedules: { + ...(current.state.schedules || {}), + [claim.profileKey]: nextSchedule, + }, consumedBuckets: { ...(current.state.consumedBuckets || {}), - [claim.profileKey]: claim.bucket, + [legacyKey]: consumedBucket, }, - lastConsumedBucket: claim.bucket, - lastProfileKey: claim.profileKey, + lastConsumedBucket: consumedBucket, + lastProfileKey: legacyKey, claim: null, lastOutcome: outcome, updatedAt: new Date().toISOString(), @@ -336,7 +452,7 @@ function renderTemplate(template, placeholders) { )); } -async function announceReward(gameServerId, player, profile, granted) { +async function announceReward(gameServerId, player, profile, granted, intervalMinutes) { const delivery = normalizeMessageDelivery(profile.messageDelivery); if (delivery === 'off') return; if (!profile.rewardMessage) return; @@ -348,7 +464,7 @@ async function announceReward(gameServerId, player, profile, granted) { items: itemSummary, itemCount: granted.reduce((sum, item) => sum + item.amount, 0), profileName: profile.profileName, - intervalMinutes: profile.intervalMinutes, + intervalMinutes, }); if (!message) return; @@ -390,9 +506,14 @@ export async function processPlaytimeItemRewards(gameServerId, mod) { let itemGrantSucceeded = false; try { const profile = resolveProfile(config, player.roles, gameServerId); - const profileKey = profileStateKey(profile); - const bucket = getPlaytimeBucket(player.playtimeSeconds, profile.intervalMinutes); - if (bucket < 1) continue; + const playtimeSeconds = Math.max(0, Number(player.playtimeSeconds) || 0); + const { profileKey, schedule } = await ensureSchedule( + gameServerId, + moduleId, + player.playerId, + profile, + ); + if (playtimeSeconds < schedule.eligibleAtPlaytimeSeconds) continue; if (profile.items.length === 0) { console.error(`playtime-item-rewards: profile ${profile.profileName} has no valid items for player ${player.name}`); @@ -400,14 +521,29 @@ export async function processPlaytimeItemRewards(gameServerId, mod) { continue; } - claim = await acquireBucketClaim(gameServerId, moduleId, player.playerId, profileKey, bucket); + claim = await acquireScheduleClaim( + gameServerId, + moduleId, + player.playerId, + profileKey, + schedule, + playtimeSeconds, + ); if (!claim) continue; summary.playersEligible++; const selected = selectItems(profile); if (selected.length === 0) { - const completed = await completeBucket(gameServerId, moduleId, player.playerId, claim, 'no-drop'); - if (!completed) throw new Error('could not persist no-drop bucket completion'); + const completed = await completeCycle( + gameServerId, + moduleId, + player.playerId, + claim, + 'no-drop', + playtimeSeconds, + profile, + ); + if (!completed) throw new Error('could not persist no-drop cycle completion'); claim = null; summary.noDrop++; console.log(`playtime-item-rewards: no item chance passed for ${player.name} using profile ${profile.profileName}`); @@ -415,7 +551,7 @@ export async function processPlaytimeItemRewards(gameServerId, mod) { } claim = await markDeliveryStarted(gameServerId, moduleId, player.playerId, claim); - if (!claim) throw new Error('lost bucket claim before item delivery'); + if (!claim) throw new Error('lost cycle claim before item delivery'); const result = await grantItems(gameServerId, player, selected); if (result.granted.length === 0) { @@ -428,9 +564,17 @@ export async function processPlaytimeItemRewards(gameServerId, mod) { itemGrantSucceeded = true; let stateCompleted = false; try { - stateCompleted = await completeBucket(gameServerId, moduleId, player.playerId, claim, 'granted'); + stateCompleted = await completeCycle( + gameServerId, + moduleId, + player.playerId, + claim, + 'granted', + playtimeSeconds, + profile, + ); } catch (err) { - console.error(`playtime-item-rewards: items granted but bucket completion failed for ${player.name}: ${err}`); + console.error(`playtime-item-rewards: items granted but cycle completion failed for ${player.name}: ${err}`); } if (!stateCompleted) { summary.failed++; @@ -447,7 +591,7 @@ export async function processPlaytimeItemRewards(gameServerId, mod) { ); try { - await announceReward(gameServerId, player, profile, result.granted); + await announceReward(gameServerId, player, profile, result.granted, claim?.intervalMinutes || schedule.intervalMinutes); } catch (err) { console.error(`playtime-item-rewards: items granted but announcement failed for ${player.name}: ${err}`); } diff --git a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts index 3e5a7ef..7447aed 100644 --- a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +++ b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts @@ -49,6 +49,10 @@ type ConfigProperty = { }; type RewardState = { + schedules?: Record; lastConsumedBucket?: number; lastOutcome?: string; }; @@ -170,30 +174,69 @@ describe('playtime-item-rewards', () => { await Promise.all(result.data.data.map((variable) => client.variable.variableControllerDelete(variable.id))); } - it('does not create state or grant before the configured interval', async () => { + it('selects and persists one interval for the current player cycle', async () => { const playerId = ctx!.players[0].playerId; await deleteRewardState(playerId); await installModule(client, versionId, ctx!.gameServer.id, { userConfig: { ...DEFAULT_CONFIG, - playtimeIntervalMinutes: 999999, + playtimeIntervalMinutes: 120, + playtimeIntervalMaximumMinutes: 300, }, }); try { - const result = await triggerCronjob(); - assert.equal(result.success, true, `Expected cronjob success, logs: ${JSON.stringify(result.logs)}`); + const firstResult = await triggerCronjob(); + assert.equal(firstResult.success, true, `Expected cronjob success, logs: ${JSON.stringify(firstResult.logs)}`); assert.ok( - result.logs.some((msg) => msg.includes('no players reached a new playtime interval')), - `Expected below-threshold log, got: ${JSON.stringify(result.logs)}`, + firstResult.logs.some((msg) => msg.includes('no players reached a new playtime interval')), + `Expected below-threshold log, got: ${JSON.stringify(firstResult.logs)}`, ); - assert.equal(await findRewardState(playerId), null); + + const firstState = await findRewardState(playerId); + assert.ok(firstState?.schedules, `Expected a persisted schedule, got: ${JSON.stringify(firstState)}`); + const firstSchedules = firstState.schedules; + const firstSchedule = Object.values(firstSchedules)[0]; + assert.equal(Object.keys(firstSchedules).length, 1); + assert.ok(firstSchedule, `Expected one schedule, got: ${JSON.stringify(firstSchedules)}`); + assert.ok(firstSchedule.intervalMinutes >= 120 && firstSchedule.intervalMinutes <= 300); + assert.equal(firstSchedule.eligibleAtPlaytimeSeconds, firstSchedule.intervalMinutes * 60); + + const secondResult = await triggerCronjob(); + assert.equal(secondResult.success, true, `Expected second cronjob success, logs: ${JSON.stringify(secondResult.logs)}`); + const secondState = await findRewardState(playerId); + assert.deepEqual(secondState?.schedules, firstSchedules, 'Expected repeated cron runs to reuse the selected interval'); + } finally { + await uninstallModule(client, moduleId!, ctx!.gameServer.id); + } + }); + + it('normalizes a maximum below the minimum to a fixed minimum interval', async () => { + const playerId = ctx!.players[0].playerId; + await deleteRewardState(playerId); + await installModule(client, versionId, ctx!.gameServer.id, { + userConfig: { + ...DEFAULT_CONFIG, + playtimeIntervalMinutes: 300, + playtimeIntervalMaximumMinutes: 120, + }, + }); + + try { + const result = await triggerCronjob(); + assert.equal(result.success, true, `Expected cronjob success, logs: ${JSON.stringify(result.logs)}`); + const state = await findRewardState(playerId); + assert.ok(state?.schedules, `Expected a persisted schedule, got: ${JSON.stringify(state)}`); + const schedule = Object.values(state.schedules)[0]; + assert.ok(schedule, `Expected one schedule, got: ${JSON.stringify(state.schedules)}`); + assert.equal(schedule.intervalMinutes, 300); + assert.equal(schedule.eligibleAtPlaytimeSeconds, 300 * 60); } finally { await uninstallModule(client, moduleId!, ctx!.gameServer.id); } }); - it.skip('consumes an eligible bucket once and skips a second run in the same bucket (Paper live test)', () => { + it.skip('consumes an eligible cycle once and skips a second run before the next threshold (Paper live test)', () => { // @takaro/mock-gameserver does not expose or advance playtimeSeconds. This exact // positive path is exercised against Paper in the mandatory live verification. }); From 1741d7d31d6e8f166f5b8db0121f5ee940699f2d Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:15:02 +0200 Subject: [PATCH 8/9] test(playtime-item-rewards): verify random intervals live --- ...-playtime-item-rewards-random-intervals.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/verification/2026-07-23-playtime-item-rewards-random-intervals.md diff --git a/docs/verification/2026-07-23-playtime-item-rewards-random-intervals.md b/docs/verification/2026-07-23-playtime-item-rewards-random-intervals.md new file mode 100644 index 0000000..8f6693e --- /dev/null +++ b/docs/verification/2026-07-23-playtime-item-rewards-random-intervals.md @@ -0,0 +1,130 @@ +# Playtime Item Rewards Random Interval Verification + +Date: 2026-07-23 + +## Scope + +This verification covers the persistent per-player random interval range added +to `playtime-item-rewards`, together with private reward delivery, accumulated +playtime across a disconnect, and same-cycle duplicate prevention. + +The verified source commit was `f761b06`. Live verification used a temporary +module named `review-playtime-range-20260723` so the canonical module and shared +catalog entry were not replaced during the test. + +## Automated verification + +The focused real-Takaro integration suite passed with 3 tests passed, 0 failed, +and 1 explicit Paper-only skip. It proved: + +- the editable maximum interval exists in the default and role-override schemas; +- a selected value in a 120-300 minute range is persisted across cron runs; +- a configured 300-minute minimum and 120-minute maximum normalizes safely to a + fixed 300-minute interval. + +The following checks also passed: + +```bash +npm run build +npm run typecheck +node --check modules/playtime-item-rewards/src/functions/playtime-item-reward-helpers.js +node --check modules/playtime-item-rewards/src/cronjobs/grant-playtime-item-rewards/index.js +``` + +The test runner still emits the repository's unrelated local PostgreSQL +authentication warning. The suite uses the configured real Takaro API and +completed successfully. + +## Paper environment + +- Game server: `unique-mc-identity` +- Game server ID: `db94b768-740a-4625-a6cb-b62f7b21a5ca` +- Player: `Bot_priv23` +- Player ID: `933fec58-20e7-4766-861c-74b5918cc5a1` +- Game ID: `275cd8d4-aef4-355b-85df-5454019c4df0` +- Temporary module ID: `5d3592e6-60ab-400d-8489-d817de0b1cc7` +- Temporary version ID: `9768d6c6-4280-449a-994f-fd71f7d85517` +- Temporary cronjob ID: `d01f7f61-e8c5-45a8-a059-690ef03b7580` + +Configuration: + +```json +{ + "playtimeIntervalMinutes": 1, + "playtimeIntervalMaximumMinutes": 2, + "selectionMode": "single", + "rewardMessage": "RANGECHECK {playerName} got {items} after {intervalMinutes}m", + "messageDelivery": "private", + "items": [ + { + "name": "stone", + "amount": 1, + "quality": "", + "dropChance": 100, + "enabled": true + } + ], + "roleOverrides": [] +} +``` + +## Live results + +Before connecting, Takaro held 204 seconds of accumulated playtime for the +offline player. This exceeded either possible first threshold in the configured +1-2 minute range. + +Cron event `5bfea4c5-1b7b-4764-9f00-3f5267c81769` completed with +`success: true`. It granted one stone, increasing the bot inventory from one to +two stones, and sent this message: + +```text +RANGECHECK Bot_priv23 got 1x stone after 1m +``` + +The Takaro action contained only the rewarded player's recipient: + +```json +{ + "recipient": { + "gameId": "275cd8d4-aef4-355b-85df-5454019c4df0" + } +} +``` + +The bot client received the same private message. The `1m` value proves that +`{intervalMinutes}` renders the selected completed-cycle interval rather than a +range bound. + +After completion, the persisted state selected a new two-minute interval and +stored its accumulated-playtime threshold: + +```json +{ + "schedules": { + "[\"default\",1,2]": { + "intervalMinutes": 2, + "eligibleAtPlaytimeSeconds": 324 + } + }, + "claim": null, + "lastOutcome": "granted" +} +``` + +Immediate repeat event `15d9bcfa-48fb-4e97-ae2f-a5285cb41d6b` completed with +`success: true`, `eligible 0`, and `granted 0`. The next schedule remained +exactly two minutes at threshold 324, proving that a cron rerun does not reroll +or duplicate the current cycle. + +The bot then disconnected. Takaro advanced its accumulated playtime to 386 +seconds while the reward schedule remained unchanged. After reconnecting the +same player, and before any new cron trigger, the schedule was still exactly two +minutes at threshold 324 and the inventory still contained two stones. This +proves that disconnect/reconnect does not reset or reroll the stored cycle. + +## Cleanup + +The temporary module installation and module were deleted, both test bot +processes were stopped, and Paper reported zero online players. A final Takaro +search returned no temporary module with either live-review name. From 303ccb6e7b91059b8da64a0a54b741f8a1507329 Mon Sep 17 00:00:00 2001 From: Harambe Date: Fri, 24 Jul 2026 14:25:59 +0000 Subject: [PATCH 9/9] fix(playtime-item-rewards): show max interval setting --- modules/playtime-item-rewards/module.json | 3 ++- .../playtime-item-rewards/test/playtime-item-rewards.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/playtime-item-rewards/module.json b/modules/playtime-item-rewards/module.json index d96f499..423c7c2 100644 --- a/modules/playtime-item-rewards/module.json +++ b/modules/playtime-item-rewards/module.json @@ -18,7 +18,8 @@ "playtimeIntervalMaximumMinutes": { "type": "integer", "minimum": 1, - "description": "Optional maximum reward interval in accumulated playtime minutes. Leave empty or set equal to playtimeIntervalMinutes for a fixed interval; set it higher to choose a random interval per player and reward cycle." + "default": 120, + "description": "Maximum reward interval in accumulated playtime minutes. Set equal to playtimeIntervalMinutes for a fixed interval; set it higher to choose a random interval per player and reward cycle." }, "selectionMode": { "type": "string", diff --git a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts index 7447aed..bf05ee6 100644 --- a/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts +++ b/modules/playtime-item-rewards/test/playtime-item-rewards.test.ts @@ -111,9 +111,10 @@ describe('playtime-item-rewards', () => { assert.equal(manifest.config?.properties?.messageDelivery?.default, 'broadcast'); assert.equal(manifest.config?.properties?.playtimeIntervalMaximumMinutes?.type, 'integer'); assert.equal(manifest.config?.properties?.playtimeIntervalMaximumMinutes?.minimum, 1); + assert.equal(manifest.config?.properties?.playtimeIntervalMaximumMinutes?.default, 120); assert.match( manifest.config?.properties?.playtimeIntervalMaximumMinutes?.description ?? '', - /leave.*empty.*fixed/i, + /set.*equal.*fixed/i, ); assert.equal( manifest.config?.properties?.roleOverrides?.items?.properties?.playtimeIntervalMaximumMinutes?.type,