ONE TRANSACTION, SEVERAL VERIFIED WRITES
A confirmed mine updates world, inventory, source counters, and PlayerSkills atomically
For an ordinary mine, the browser resolves the canonical block and authoritative Backpack. It builds one transaction containing the Chunk reward instruction, optional equipment and position writes, and a Skills synchronization instruction. Skills receives Chunk Progress, PlayerProfile, Backpack, and the mined coordinate.
When a coordinate is supplied, Skills reads the Instructions sysvar and proves that an approved Chunk mining instruction for the same owner and coordinate appears earlier in the transaction. Generic source rules independently validate every source account by program owner, PDA seeds, magic, owner, GlobalConfig, metric offset, and width.
Solana atomicity is the safety boundary. If source validation, mining proof, level recomputation, or PlayerSkills allocation fails, the earlier tentative mine and Backpack writes roll back. After confirmation, local world state applies only committed blocks and starts a forced PlayerSkills refresh.
Verifies generated terrain and writes broken-block state.
Stores accepted resource volume, density-derived mass, and mine sequence.
Consumes validated source deltas and writes ten XP totals and levels.
No earlier instruction remains committed when a later instruction fails.
SIX CONFIGURED SOURCE RULES
Validated program counters feed all ten skills through explicit coefficients
The current table has six generic source rules. Three read Chunk Progress: Precision XP, Exploration XP, and explored chunk count. One reads Game/Smelting Progress. Two read PlayerProfile: forging XP divided into 100-XP units and forge count. Each rule names source program, PDA seed layout, magic, owner and GlobalConfig offsets, metric width and offset, maximum delta, divisor, and ten XP rates.
One verified event family can feed several skills. New Chunk Precision units feed Precision Gathering, Exploration, Stamina, Strength, and Appraisal. New Smelting units feed Smelting, Forging, Craftsmanship, Stamina, and Appraisal. These are treasury-configured conversions, not browser heuristics.
The configured coefficients are visible in initialization source and on-chain rule bytes. Clients should decode the table rather than duplicate them as authoritative constants.
Rates: Precision 115, Exploration 44, Stamina 9, Strength 18, Appraisal 22.
Feed Exploration plus smaller Stamina, Strength, and Appraisal awards.
Rates: Smelting 250, Forging 38, Craftsmanship 28, Stamina 6, Appraisal 36.
Feed Forging, Craftsmanship, Stamina, Strength, and Appraisal.
OWNER PDA AND MONOTONIC CURSORS
Owner scoping and source cursors replace browser storage and maximum merging
PlayerSkills is derived from [player-skills-v1, GlobalConfig, owner] under Skills. Two wallets therefore have different accounts on the same browser, and another device reads the same public ledger for the same wallet.
For each generic rule, PlayerSkills stores a cursor and initialized bit. Synchronization rejects a source counter that moves backward. Otherwise it consumes only current minus previous, clamps that delta to the rule maximum, advances the cursor, and adds the scaled XP difference. When a source remains more than maxDeltaPerSync ahead, repeated synchronizations continue bounded catch-up; a repeat has zero delta only after the cursor equals the source counter.
All configured generic rules enable bounded first-sync backfill. If an older counter exceeds that bound, later syncs continue from the cursor. There is no localStorage XP fallback, ownerless XP key, per-skill maximum, or migration branch in the final testnet design.
Skills program ID is also part of PDA derivation.
The cursor already equals the source counter.
An initialized counter cannot be lower than its cursor.
The cursor advances by at most maxDeltaPerSync and can continue later.
ONE CUMULATIVE THRESHOLD TABLE
The program and Profile use the same ten cumulative thresholds per skill
SkillRuleTable stores ten strictly increasing u64 thresholds for each of ten skills. During synchronization, Skills compares every PlayerSkills XP total with all ten thresholds, writes level 0 through 10, and records the table revision.
The browser validates table program owner, PDA, length, magic, version, bump, active flag, GlobalConfig, skill count, and every increasing threshold. Profile uses those cumulative totals for current-tier requirement and progress. It does not rebuild a separate authoritative curve from xpBase or xpGrowth.
Smelting reaches levels 1, 2, and 10 at cumulative totals 1,200, 4,738, and 192,519. At level 1, the displayed tier requirement is 4,738 − 1,200 = 3,538 XP.
Ten cached level bytes correspond to ten threshold arrays.
Zero, duplicate, or decreasing entries are rejected.
Clamped to the current tier requirement.
Shows which table revision produced cached levels.
ACTION-SPECIFIC SYNCHRONIZATION
Mining, Smelting, and Forging synchronize PlayerSkills at settlement
Ordinary mining, tree felling, bulk mining, and primary support-collapse mining append createSyncPlayerSkillsInstruction after authoritative writes. Synchronization receives a coordinate for mining proof and source accounts needed by generic and burden rules.
Smelting appends Skills synchronization in the same wallet transaction as executeSmelting. Its source list includes Game/Smelting Progress, PlayerProfile, and Backpack. Verified Forging first reads PlayerSkills as its authoritative level input, then appends Skills synchronization after the forge instruction in the same wallet transaction; PlayerProfile and Backpack sources therefore expose the just-written forging counters to that synchronization.
After confirmation, mining and Smelting force the Player synchronization module to refresh. This removes the visible delay where a transaction succeeded but the skill panel waited for its polling cooldown.
Includes coordinate proof and pre-mine Backpack mass state.
A split range receives its own proof and cursor update.
Both instructions settle or roll back together.
The synchronization follows the forge write in the same wallet transaction.
Bypasses the ordinary 18-second cooldown.
PROOF-BOUND MINING RULES
Travel distance and carried mass across chunks add specialized Swiftness and Burden XP
The travel rule records the latest proven mining coordinate. From the second mine onward, it computes three-dimensional squared distance. At least 160 blocks awards one Swiftness XP and increments miningTravelCount; the current coordinate becomes the next reference.
Burden uses Backpack pre-mine total mass, the previous proven mining coordinate, and a monotonic mine sequence. Coordinates are floor-divided into 16-block chunks; horizontal distance is max(|delta chunk X|, |delta chunk Z|), capped at five. Every complete 20,000 grams carried earns one mass point per credited chunk, so 40 kg moved three chunks awards 6 XP. The first proven mine establishes the coordinate baseline and awards zero Burden XP.
Both rules resist replay. Travel needs a matching mining instruction in the transaction. Burden ignores an already consumed sequence, rejects regression, and stores cumulative awarded XP rather than fractional mass work. Skills reads fixed Backpack mass fields instead of scanning 99 records, keeping compute bounded.
Uses all three axes without a square root.
At most once per proven synchronization coordinate.
An incomplete mass step is not carried into a later action.
Horizontal Chebyshev distance is capped at five chunks; 40 kg across three chunks yields 6 XP.
VALIDATED READ, REVISION-AWARE CACHE
Profile validates PlayerSkills and refetches rules only when revision changes
fetchPlayerProgress derives Chunk Progress, Game/Smelting Progress, and PlayerSkills, then reads all three in one getMultipleAccountsInfo request while fetching SkillRuleTable. Every present account is validated against expected owner, layout, bump, wallet owner, and GlobalConfig.
SkillRuleTable is cached in a WeakMap keyed by Connection. Repeated refresh reuses it. If PlayerSkills.ruleRevision differs from cached table revision, the client performs exactly one forced read before returning XP, levels, thresholds, and rulesCurrent.
Profile passes chain XP, levels, and thresholds directly into skill state. An uninitialized PlayerSkills renders zeros until valid synchronization creates it. A malformed present account fails closed; it is never replaced with browser-derived XP.
Source domains remain diagnostic while PlayerSkills is authoritative.
A different connection has a different cache entry.
Prevents stale thresholds with newer cached levels.
Wrong identity, layout, or threshold order is not tolerated.
READ THE IMPLEMENTATION
Ten formulas and fourteen literal excerpts trace authoritative XP from source delta to Profile refresh
The equations cover PDA identity, cursor deltas, scaled awards, cumulative thresholds, specialized mining rules, and Smelting output. Every excerpt is a continuous substring of current source.
PlayerSkills PDA
PDA_skills = findProgramAddress([player-skills-v1, GlobalConfig, owner], SkillsProgram)The address is owner-scoped and program-scoped; browser storage is not part of its identity.
SkillRuleTable PDA
PDA_rules = findProgramAddress([skill-rules-v1, GlobalConfig], SkillsProgram)Thresholds and conversion rules are shared for the configured game domain.
Available source delta
available = initialized ? current − cursor : (backfill ? current : 0); applied = min(available, maxDeltaPerSync)This makes synchronization replay-safe and bounds catch-up work.
Scaled XP increment
gain = floor(nextCursor × rate / divisor) − floor(previousCursor × rate / divisor)Cumulative scaling preserves integer remainders without floating-point state.
Authoritative level
level = max({0} ∪ {L ∈ 1..10 | XP ≥ threshold[L]})Skills writes this level and Profile displays it.
Current tier progress
required = threshold[level + 1] − threshold[level]; current = XP − threshold[level]Level zero treats its starting threshold as zero.
Mining travel qualification
dx² + dy² + dz² ≥ 160²Squared distance exactly matches program comparison.
Burden route distance
distanceChunks = min(5, max(|floor(x₂ / 16) − floor(x₁ / 16)|, |floor(z₂ / 16) − floor(z₁ / 16)|))Y does not contribute. Euclidean floor division maps negative block coordinates to the correct chunks.
Burden XP
gain = floor(preMineMassGrams / 20,000) × distanceChunksThe first proven mine and a same-chunk mine award zero; incomplete mass below one step is not accumulated.
Smelting skill output
outputMultiplierBps = min(15,000, 10,000 + 500 × SmeltingLevel)Smelting reads PlayerSkills and applies this to every non-merge output's physical volume and integer stack quantity. Material-merge recipes instead preserve all consumed units and volume.
Production Play loads the modular entry
HTMLplay/index.html <script type="module" src="./main.js"></script>
All browser claims begin at /play/index.html and modules imported by /play/main.js.
- Line 1
Load the current game module relative to /play/.
PlayerSkills and SkillRuleTable use final seeds
JavaScriptsrc/chain/nicechunkChain.jsexport function derivePlayerSkillsPda(owner, programId = NICECHUNK_SKILLS_PROGRAM_ID) {
const normalizedOwner = typeof owner === "string" ? new PublicKey(owner) : owner;
return PublicKey.findProgramAddressSync(
[Buffer.from(playerSkillsSeed), deriveGlobalConfigPda().toBuffer(), normalizedOwner.toBuffer()],
programId,
);
}
export function deriveSkillRuleTablePda(programId = NICECHUNK_SKILLS_PROGRAM_ID) {
return PublicKey.findProgramAddressSync(
[Buffer.from(skillRuleTableSeed), deriveGlobalConfigPda().toBuffer()],
programId,
);
}
The browser derives the same Skills PDAs used by the program.
- First function
Bind PlayerSkills to GlobalConfig, owner, and Skills program.
- Second function
Bind SkillRuleTable to GlobalConfig and Skills program.
Profile skill state accepts only chain values
JavaScriptplay/play-profile-skills.jsexport function buildProfileSkillState({
chainXp = null,
chainLevels = null,
chainThresholds = null,
} = {}) {
const levels = normalizeSkillLevels(chainLevels);
const xpBySkill = normalizeSkillXp(chainXp);
const thresholdsBySkill = normalizeSkillThresholds(chainThresholds);
return {
levels,
xpBySkill,
thresholdsBySkill,
resolvedLevels: { ...levels },
source: "chain",
};
}
Final browser state has no local counter, storage fallback, or maximum merge.
- Inputs
Receive authoritative XP, levels, and thresholds.
- Return
Expose normalized chain-sourced skill state.
Profile tier progress uses cumulative thresholds
JavaScriptplay/play-profile-skills.jsexport function profileSkillExperienceRequirement(skill, level, thresholdsBySkill = null) {
if (level >= PROFILE_SKILL_MAX_LEVEL) return 0;
const thresholds = normalizedThresholdsForSkill(skill, thresholdsBySkill);
if (thresholds) {
const currentLevel = Math.max(0, Math.min(PROFILE_SKILL_MAX_LEVEL - 1, Math.trunc(level)));
const previousTotal = currentLevel > 0 ? thresholds[currentLevel - 1] : 0;
return thresholds[currentLevel] - previousTotal;
}
const nextLevel = Math.max(1, Math.min(PROFILE_SKILL_MAX_LEVEL, Math.trunc(level) + 1));
return Math.round((skill?.xpBase ?? 100) * PROFILE_SKILL_XP_REQUIREMENT_MULTIPLIER * Math.pow(nextLevel, skill?.xpGrowth ?? 1.55));
}
export function profileSkillTotalExperienceForLevel(skill, level, thresholdsBySkill = null) {
const capped = Math.max(0, Math.min(PROFILE_SKILL_MAX_LEVEL, Math.round(Number(level) || 0)));
if (capped === 0) return 0;
const thresholds = normalizedThresholdsForSkill(skill, thresholdsBySkill);
if (thresholds) return thresholds[capped - 1];
let total = 0;
for (let previousLevel = 0; previousLevel < capped; previousLevel += 1) {
total += profileSkillExperienceRequirement(skill, previousLevel);
}
return total;
}
export function profileSkillLevelFromXp(skill, xp, thresholdsBySkill = null) {
const total = Math.max(0, Math.round(Number(xp) || 0));
let level = 0;
for (let nextLevel = 1; nextLevel <= PROFILE_SKILL_MAX_LEVEL; nextLevel += 1) {
if (total < profileSkillTotalExperienceForLevel(skill, nextLevel, thresholdsBySkill)) break;
level = nextLevel;
}
return level;
}
export function profileSkillExperienceProgress(skill, level, xpBySkill = {}, thresholdsBySkill = null) {
const minimumTotal = profileSkillTotalExperienceForLevel(skill, level, thresholdsBySkill);
const rawTotal = Number(xpBySkill?.[skill.id] ?? minimumTotal);
const total = Number.isFinite(rawTotal) ? Math.max(0, Math.round(rawTotal)) : minimumTotal;
const required = profileSkillExperienceRequirement(skill, level, thresholdsBySkill);
if (level >= PROFILE_SKILL_MAX_LEVEL) {
return {
total,
current: 0,
required: 0,
ratio: 1,
label: `Total XP ${formatProfileSkillXp(total)}`,
};
}
const current = Math.max(0, Math.min(required, total - minimumTotal));
return {
total,
current,
required,
ratio: required > 0 ? current / required : 1,
label: `XP ${formatProfileSkillXp(current)}/${formatProfileSkillXp(required)}`,
};
}
Requirement, level, and progress use decoded table thresholds.
- Requirement
Subtract adjacent cumulative thresholds.
- Level
Select the highest reached total.
- Progress
Subtract the current level start from total XP.
Player progress fetch includes Skills and rules
JavaScriptsrc/chain/nicechunkChain.jsexport async function fetchPlayerProgress(ownerAddress, {
connection: connectionOverride = null,
context = gameContext,
} = {}) {
if (!ownerAddress) return null;
const owner = typeof ownerAddress === "string" ? new PublicKey(ownerAddress) : ownerAddress;
const globalConfig = deriveGlobalConfigPda();
const [chunkProgress, chunkBump] = derivePlayerProgressPdaForContext(owner, context);
const [smeltingProgress, smeltingBump] = deriveSmeltingPlayerProgressPdaForContext(owner, context);
const [playerSkills, skillsBump] = derivePlayerSkillsPda(owner);
const conn = connectionOverride ?? getNicechunkConnection();
const [accounts, initialRuleTable] = await Promise.all([
conn.getMultipleAccountsInfo([chunkProgress, smeltingProgress, playerSkills], "confirmed"),
fetchSkillRuleTable({ connection: conn }),
]);
const [chunkAccount, smeltingAccount, skillsAccount] = accounts;
const chunkDomain = decodePlayerProgressDomain({
account: chunkAccount,
expectedProgramId: context.chunkProgramId,
expectedBump: chunkBump,
owner,
globalConfig,
});
const smeltingDomain = decodePlayerProgressDomain({
account: smeltingAccount,
expectedProgramId: context.smeltingProgramId,
expectedBump: smeltingBump,
owner,
globalConfig,
});
const skillsDomain = decodePlayerSkillsDomain({
account: skillsAccount,
expectedBump: skillsBump,
owner,
globalConfig,
});
const skillRuleTable = skillsDomain && initialRuleTable.revision !== skillsDomain.ruleRevision
? await fetchSkillRuleTable({ connection: conn, force: true })
: initialRuleTable;
const emptySkills = Object.fromEntries(PLAYER_SKILL_IDS.map((skillId) => [skillId, 0]));
return {
publicKey: chunkProgress.toBase58(),
smeltingPublicKey: smeltingProgress.toBase58(),
owner: owner.toBase58(),
precisionGatheringXp: chunkDomain?.precisionGatheringXp ?? 0,
smeltingXp: smeltingDomain?.smeltingXp ?? 0,
explorationXp: chunkDomain?.explorationXp ?? 0,
playerSkillsPublicKey: playerSkills.toBase58(),
playerSkillsInitialized: Boolean(skillsDomain),
skillXp: skillsDomain?.xp ?? emptySkills,
skillLevels: skillsDomain?.levels ?? emptySkills,
skillCursorMask: skillsDomain?.cursorMask ?? 0,
skillThresholds: skillRuleTable.thresholds,
skillRuleRevision: skillsDomain?.ruleRevision ?? 0,
skillRuleTableRevision: skillRuleTable.revision,
skillRulesCurrent: Boolean(skillsDomain && skillsDomain.ruleRevision === skillRuleTable.revision),
programId: context.chunkProgramId.toBase58(),
smeltingProgramId: context.smeltingProgramId.toBase58(),
skillsProgramId: NICECHUNK_SKILLS_PROGRAM_ID.toBase58(),
};
}
The aggregate read returns PlayerSkills as the ten-skill authority.
- Batch
Read Chunk, Smelting, and PlayerSkills while loading rules.
- Revision
Force one refetch when revisions differ.
- Return
Expose XP, levels, thresholds, and revision metadata.
Rule decoding validates every threshold
JavaScriptsrc/chain/nicechunkChain.jsfunction decodeSkillRuleTableDomain({ account, expectedBump, globalConfig }) {
if (!account?.data?.length) throw new Error("skill-rule-table-uninitialized");
if (!account.owner?.equals?.(NICECHUNK_SKILLS_PROGRAM_ID)) {
throw new Error("skill-rule-table-owner-invalid");
}
const data = Buffer.from(account.data);
const valid = data.length === skillRuleTableLength
&& data.subarray(0, 8).toString("utf8") === skillRuleTableMagic
&& data.readUInt16LE(8) === skillRuleTableVersion
&& data.readUInt8(10) === expectedBump
&& data.readUInt8(11) === 1
&& data.readUInt8(skillRuleTableSkillCountOffset) === PLAYER_SKILL_IDS.length
&& data.subarray(44, 76).equals(globalConfig.toBuffer());
if (!valid) throw new Error("skill-rule-table-domain-invalid");
const thresholds = {};
for (let skillIndex = 0; skillIndex < PLAYER_SKILL_IDS.length; skillIndex += 1) {
const values = [];
let previous = 0;
for (let levelIndex = 0; levelIndex < 10; levelIndex += 1) {
const offset = skillRuleTableThresholdsOffset + (skillIndex * 10 + levelIndex) * 8;
const value = safeChainInteger(data.readBigUInt64LE(offset), "skill threshold");
if (value <= previous) throw new Error("skill-rule-table-thresholds-invalid");
values.push(value);
previous = value;
}
thresholds[PLAYER_SKILL_IDS[skillIndex]] = Object.freeze(values);
}
return Object.freeze({
revision: data.readUInt32LE(skillRuleTableRevisionOffset),
thresholds: Object.freeze(thresholds),
});
}
Substituted identity, invalid layout, or non-increasing thresholds are rejected.
- Identity
Validate owner, size, magic, version, bump, state, skill count, and GlobalConfig.
- Thresholds
Decode ten safe increasing values per skill.
Synchronization carries mining proof and sources
JavaScriptsrc/chain/nicechunkChain.jsexport function createSyncPlayerSkillsInstruction({
payer,
owner,
sourceAccounts = [],
miningCoordinate = null,
programId = NICECHUNK_SKILLS_PROGRAM_ID,
}) {
const normalizedPayer = typeof payer === "string" ? new PublicKey(payer) : payer;
const normalizedOwner = typeof owner === "string" ? new PublicKey(owner) : owner;
const [playerSkills] = derivePlayerSkillsPda(normalizedOwner, programId);
const [ruleTable] = deriveSkillRuleTablePda(programId);
const uniqueSources = [...new Map(sourceAccounts.map((source) => {
const publicKey = typeof source === "string" ? new PublicKey(source) : source;
return [publicKey.toBase58(), publicKey];
})).values()];
const coordinate = miningCoordinate ? normalizeSkillMiningCoordinate(miningCoordinate) : null;
const data = Buffer.alloc(coordinate ? 13 : 1);
data.writeUInt8(3, 0);
if (coordinate) {
data.writeInt32LE(coordinate.x, 1);
data.writeInt32LE(coordinate.y, 5);
data.writeInt32LE(coordinate.z, 9);
}
return new TransactionInstruction({
programId,
keys: [
{ pubkey: normalizedPayer, isSigner: true, isWritable: true },
{ pubkey: normalizedOwner, isSigner: false, isWritable: false },
{ pubkey: playerSkills, isSigner: false, isWritable: true },
{ pubkey: ruleTable, isSigner: false, isWritable: false },
{ pubkey: deriveGlobalConfigPda(), isSigner: false, isWritable: false },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
...(coordinate ? [{ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }] : []),
...uniqueSources.map((pubkey) => ({ pubkey, isSigner: false, isWritable: false })),
],
data,
});
}
The Instructions sysvar appears only when a mining coordinate must be proven.
- Payload
Encode tag or tag plus coordinate.
- Accounts
Pass Skills state, rules, proof sysvar, and deduplicated sources.
Generic rules consume bounded monotonic deltas
Rustprograms/nicechunk_skills/src/state.rs let backfill = rule.flags & RULE_FLAG_BACKFILL_ON_FIRST_SYNC != 0;
let available_delta = if initialized {
current_counter.saturating_sub(previous_cursor)
} else if backfill {
current_counter
} else {
0
};
let applied_delta = available_delta.min(rule.max_delta_per_sync);
let next_cursor = if !initialized && !backfill {
current_counter
} else {
previous_cursor
.checked_add(applied_delta)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?
};
let next_mask = state.cursor_mask | bit;
data[PLAYER_SKILLS_CURSOR_MASK_OFFSET..PLAYER_SKILLS_CURSOR_MASK_OFFSET + 4]
.copy_from_slice(&next_mask.to_le_bytes());
data[cursor_offset..cursor_offset + 8].copy_from_slice(&next_cursor.to_le_bytes());
if applied_delta == 0 {
return Ok(CounterApplyResult {
changed: !initialized || next_cursor != previous_cursor,
applied_delta,
});
}
for (skill_index, rate) in rule.xp_per_unit.iter().enumerate() {
if *rate == 0 {
continue;
}
let previous_scaled = (previous_cursor as u128)
.checked_mul(*rate as u128)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?
/ rule.unit_divisor as u128;
let next_scaled = (next_cursor as u128)
.checked_mul(*rate as u128)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?
/ rule.unit_divisor as u128;
let gained = next_scaled
.checked_sub(previous_scaled)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
let gained =
u64::try_from(gained).map_err(|_| NicechunkSkillsError::ArithmeticOverflow)?;
let xp_offset = PLAYER_SKILLS_XP_OFFSET + skill_index * 8;
let next_xp = read_u64(data, xp_offset)
.checked_add(gained)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
data[xp_offset..xp_offset + 8].copy_from_slice(&next_xp.to_le_bytes());
}
Ok(CounterApplyResult {
changed: true,
applied_delta,
})
}
The state function advances one cursor and calculates integer XP from scaled cumulative values.
- Delta
Choose backfill or monotonic difference and clamp it.
- XP
Apply every nonzero rate with the divisor.
- Write
Persist cursor and XP together.
Burden multiplies whole mass steps by proven chunk distance
Rustprograms/nicechunk_skills/src/state.rs pub fn apply_burden_mining_action(
data: &mut [u8],
owner: &Pubkey,
global_config: &Pubkey,
rule: BurdenMiningRule,
pre_mine_mass_grams: u64,
mine_sequence: u64,
coordinate: MiningCoordinate,
) -> Result<CounterApplyResult, NicechunkSkillsError> {
let state = Self::validate(data, owner, global_config)?;
rule.validate()?;
if !rule.enabled || mine_sequence == 0 {
return Ok(CounterApplyResult {
changed: false,
applied_delta: 0,
});
}
let sequence_bit = 1_u32 << BURDEN_SEQUENCE_CURSOR_INDEX;
let xp_bit = 1_u32 << BURDEN_XP_CURSOR_INDEX;
let sequence_initialized = state.cursor_mask & sequence_bit != 0;
let sequence_offset = PLAYER_SKILLS_CURSORS_OFFSET + BURDEN_SEQUENCE_CURSOR_INDEX * 8;
let previous_sequence = read_u64(data, sequence_offset);
if sequence_initialized && mine_sequence < previous_sequence {
return Err(NicechunkSkillsError::SourceCounterRegressed);
}
if sequence_initialized && mine_sequence == previous_sequence {
return Ok(CounterApplyResult {
changed: false,
applied_delta: 0,
});
}
let xp_cursor_offset = PLAYER_SKILLS_CURSORS_OFFSET + BURDEN_XP_CURSOR_INDEX * 8;
let previous_awarded_xp = if state.cursor_mask & xp_bit != 0 {
read_u64(data, xp_cursor_offset)
} else {
0
};
let has_previous =
data[PLAYER_SKILLS_MINING_FLAGS_OFFSET] & PLAYER_SKILLS_FLAG_HAS_LAST_MINE != 0;
let distance_chunks = if has_previous {
burden_chunk_distance(
MiningCoordinate {
x: read_i32(data, PLAYER_SKILLS_LAST_MINE_X_OFFSET),
y: read_i32(data, PLAYER_SKILLS_LAST_MINE_Y_OFFSET),
z: read_i32(data, PLAYER_SKILLS_LAST_MINE_Z_OFFSET),
},
coordinate,
rule.chunk_size_blocks,
)
.min(rule.max_distance_chunks as u64)
} else {
0
};
let mass_points = pre_mine_mass_grams / rule.mass_step_grams;
let gained_xp = mass_points
.checked_mul(distance_chunks)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
let next_awarded_xp = previous_awarded_xp
.checked_add(gained_xp)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
if gained_xp > 0 {
let xp_offset = PLAYER_SKILLS_XP_OFFSET + rule.skill_index as usize * 8;
let next_xp = read_u64(data, xp_offset)
.checked_add(gained_xp)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
data[xp_offset..xp_offset + 8].copy_from_slice(&next_xp.to_le_bytes());
}
let next_mask = state.cursor_mask | xp_bit | sequence_bit;
data[PLAYER_SKILLS_CURSOR_MASK_OFFSET..PLAYER_SKILLS_CURSOR_MASK_OFFSET + 4]
.copy_from_slice(&next_mask.to_le_bytes());
data[xp_cursor_offset..xp_cursor_offset + 8]
.copy_from_slice(&next_awarded_xp.to_le_bytes());
data[sequence_offset..sequence_offset + 8].copy_from_slice(&mine_sequence.to_le_bytes());
Ok(CounterApplyResult {
changed: true,
applied_delta: gained_xp,
})
}
The program derives chunk distance from the previous proven coordinate, multiplies it by whole mass steps, and prevents duplicate mine sequences.
- Replay
Reject regression and ignore a repeated sequence.
- Route
Measure capped horizontal chunk distance and multiply it by complete mass steps.
- Writes
Store XP, cumulative Burden awards, and the consumed sequence.
Travel uses three-dimensional distance
Rustprograms/nicechunk_skills/src/state.rs pub fn record_mining_coordinate(
data: &mut [u8],
owner: &Pubkey,
global_config: &Pubkey,
coordinate: MiningCoordinate,
rule: MiningTravelRule,
updated_slot: u64,
) -> Result<bool, NicechunkSkillsError> {
Self::validate(data, owner, global_config)?;
rule.validate()?;
let has_previous =
data[PLAYER_SKILLS_MINING_FLAGS_OFFSET] & PLAYER_SKILLS_FLAG_HAS_LAST_MINE != 0;
let qualifies = rule.enabled
&& has_previous
&& mining_distance_reaches(
MiningCoordinate {
x: read_i32(data, PLAYER_SKILLS_LAST_MINE_X_OFFSET),
y: read_i32(data, PLAYER_SKILLS_LAST_MINE_Y_OFFSET),
z: read_i32(data, PLAYER_SKILLS_LAST_MINE_Z_OFFSET),
},
coordinate,
rule.minimum_distance,
);
if qualifies {
let skill_index = rule.skill_index as usize;
let xp_offset = PLAYER_SKILLS_XP_OFFSET + skill_index * 8;
let next_xp = read_u64(data, xp_offset)
.checked_add(rule.xp_award as u64)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
data[xp_offset..xp_offset + 8].copy_from_slice(&next_xp.to_le_bytes());
let next_count = read_u64(data, PLAYER_SKILLS_MINING_TRAVEL_COUNT_OFFSET)
.checked_add(1)
.ok_or(NicechunkSkillsError::ArithmeticOverflow)?;
data[PLAYER_SKILLS_MINING_TRAVEL_COUNT_OFFSET
..PLAYER_SKILLS_MINING_TRAVEL_COUNT_OFFSET + 8]
.copy_from_slice(&next_count.to_le_bytes());
}
data[PLAYER_SKILLS_LAST_MINE_X_OFFSET..PLAYER_SKILLS_LAST_MINE_X_OFFSET + 4]
.copy_from_slice(&coordinate.x.to_le_bytes());
data[PLAYER_SKILLS_LAST_MINE_Y_OFFSET..PLAYER_SKILLS_LAST_MINE_Y_OFFSET + 4]
.copy_from_slice(&coordinate.y.to_le_bytes());
data[PLAYER_SKILLS_LAST_MINE_Z_OFFSET..PLAYER_SKILLS_LAST_MINE_Z_OFFSET + 4]
.copy_from_slice(&coordinate.z.to_le_bytes());
data[PLAYER_SKILLS_MINING_FLAGS_OFFSET] |= PLAYER_SKILLS_FLAG_HAS_LAST_MINE;
data[PLAYER_SKILLS_UPDATED_SLOT_OFFSET..PLAYER_SKILLS_UPDATED_SLOT_OFFSET + 8]
.copy_from_slice(&updated_slot.to_le_bytes());
Ok(qualifies)
}
The previous coordinate and count live in PlayerSkills.
- Qualification
Compare current and last proven coordinates.
- Award
Add configured XP and count once.
- Update
Store current coordinate for next mine.
Levels use the current rule revision
Rustprograms/nicechunk_skills/src/state.rs pub fn recompute_levels(
data: &mut [u8],
owner: &Pubkey,
global_config: &Pubkey,
rule_table_data: &[u8],
updated_slot: u64,
) -> ProgramResult {
Self::validate(data, owner, global_config)?;
let rules = RuleTableState::validate(rule_table_data, global_config)?;
for skill_index in 0..SKILL_COUNT {
let xp = read_u64(data, PLAYER_SKILLS_XP_OFFSET + skill_index * 8);
let mut level = 0_u8;
let mut previous = 0_u64;
for level_index in 0..LEVEL_COUNT {
let threshold =
RuleTableState::threshold(rule_table_data, skill_index, level_index)?;
if threshold == 0 || threshold <= previous {
return Err(NicechunkSkillsError::InvalidThresholds.into());
}
if xp >= threshold {
level = (level_index + 1) as u8;
}
previous = threshold;
}
data[PLAYER_SKILLS_LEVELS_OFFSET + skill_index] = level;
}
data[PLAYER_SKILLS_RULE_REVISION_OFFSET..PLAYER_SKILLS_RULE_REVISION_OFFSET + 4]
.copy_from_slice(&rules.revision.to_le_bytes());
data[PLAYER_SKILLS_UPDATED_SLOT_OFFSET..PLAYER_SKILLS_UPDATED_SLOT_OFFSET + 8]
.copy_from_slice(&updated_slot.to_le_bytes());
Ok(())
}
}
Every sync refreshes all ten levels and records the table revision.
- Validation
Validate state and rules against GlobalConfig.
- Loop
Compare XP with ten thresholds.
- Revision
Store the revision used.
Ordinary mining appends Skills sync
JavaScriptsrc/chain/nicechunkChain.js tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: miningComputeUnitLimit }));
if (baselineInstruction) tx.add(baselineInstruction);
tx.add(createMineBlockWithRewardsInstruction({
authority: session.keypair.publicKey,
block,
owner: provider.publicKey,
backpack: equippedBackpack.publicKey,
actionId: miningActionId,
expectedBlockId: canonicalBlock.blockId,
context,
}));
addEquipmentDurabilityInstructions(tx, {
authority: session.keypair.publicKey,
owner: provider.publicKey,
damage: options.durabilityDamage,
});
const playerPositionSaved = maybeAddPlayerPositionUpdateInstruction(tx, {
provider,
session,
position: playerPositionForResourceMine(canonicalBlock, options),
});
tx.add(createSyncPlayerSkillsInstruction({
payer: session.keypair.publicKey,
owner: provider.publicKey,
sourceAccounts: [
chunkProgress,
derivePlayerProfilePda(provider.publicKey)[0],
equippedBackpack.publicKey,
],
miningCoordinate: canonicalBlock,
}));
World, equipment, position, and skill writes share one transaction.
- Mine
Commit canonical block and reward.
- Skills
Pass source PDAs, Backpack, and coordinate.
- Send
Sign after assembling all instructions.
Confirmed mining forces Skills refresh
JavaScriptplay/play-chain-session.js if (action === "mine") {
void Promise.resolve(refreshPlayerProgress({ force: true, quiet: true })).catch(() => null);
}
Profile can update without waiting for background polling.
- Condition
Run only after confirmed mining.
- Call
Force Player progress refresh without blocking local confirmation.
Confirmed Smelting refreshes Backpack and Skills
JavaScriptplay/play-smelting.js const refreshResult = await refreshBackpack({
previousUpdatedSlot: snapshot.updatedSlot,
force: true,
});
if (!refreshResult?.ok) {
console.warn("[NiceChunk Smelting] Transaction confirmed but PDA refresh is pending.", refreshResult);
}
const playerRefreshResult = await refreshPlayerProgress({ force: true, quiet: true });
if (!playerRefreshResult?.ok) {
console.warn("[NiceChunk Smelting] Transaction confirmed but PlayerSkills refresh is pending.", playerRefreshResult);
}
The result is followed by authoritative inventory and skill reads.
- Backpack
Wait for post-transaction inventory.
- Skills
Force PlayerSkills and rule metadata refresh.
IMPLEMENTATION EVIDENCE
Where these claims come from
Each claim is intentionally scoped to a concrete implementation path. These references are for verification, not decoration.
play/index.html
Loads /play/main.js as production game entry.
play/play-profile-skills.js
Builds presentation from chain XP, levels, and thresholds only.
play/play-profile-ui.js
Passes PlayerSkills and SkillRuleTable values into Profile.
play/play-chain-player.js
Exposes Skills revision metadata through player refresh.
play/play-chain-session.js
Refreshes PlayerSkills after confirmed mining.
play/play-smelting.js
Refreshes Backpack and PlayerSkills after confirmed Smelting.
src/chain/nicechunkChain.js
Derives, validates, caches, and submits Skills accounts.
sdk/nicechunk-skills.ts
Defines final Skills layouts, PDAs, instructions, and decoders.
scripts/initialize-skill-rules.ts
Defines thresholds, six source rules, travel, burden, and treasury authority.
programs/nicechunk_skills/src/lib.rs
Validates synchronization accounts, source PDAs, and mining proof.
programs/nicechunk_skills/src/state.rs
Owns cursors, mining awards, thresholds, and levels.
programs/nicechunk_chunk/src/lib.rs
Reads authoritative Precision and Exploration levels.
programs/nicechunk_smelting/src/lib.rs
Reads authoritative Smelting level and applies output.
programs/nicechunk_backpack/src/lib.rs
Reads authoritative Forging level.
tests/nicechunk_skills.ts
Checks Skills SDK account and instruction contract.
play/tests/player-progress-chain.test.mjs
Checks Skills validation, thresholds, revision, and caching.
play/tests/play-skill-movement.test.mjs
Checks chain levels, thresholds, and movement.
play/tests/bulk-mining-chain-state-browser.test.mjs
Checks confirmed bulk mining and immediate Skills refresh.