Skip to content
14 changes: 14 additions & 0 deletions .claude/skills/testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,26 @@ For comprehensive testing documentation, read [docs/TESTING_IMPLEMENTATION.md](d
- Extract individual components rather than composite calculations
- Parameters are all calculation inputs (functions or state, any domain)
- Test multiple output scenarios to validate formulas work generally
- Game functions may return raw values (0/1 booleans, raw multipliers) that the domain transforms with hardcoded constants. Normalize the extraction expression with arithmetic so it matches the domain's output format (e.g., `0.3 * 100 * EventShopOwned(19)` to yield 0 or 30)

**When writing parameter tests:**
- Look for corresponding domain code
- Ask developer if unsure about implementation status
- Throw explicit errors for confirmed missing implementations

**Failing tests are intentional signals — never suppress them:**
- Do NOT use `it.skip()`, `it.todo()`, `xit()`, or any other mechanism to hide a failing test
- Do NOT add tolerances to make a failing test pass — tolerances mask real discrepancies
- Do NOT remove tests because they fail — a failing test documents a known gap in the domain
- Failing tests are the correct way to track what still needs to be implemented or fixed (e.g., AllTalentLV support, missing domain features)

**What NOT to test:**
- Do not write tests that simply validate parsed save data matches model fields (e.g., "level in Spelunk[45][0] == bonus.level"). Parsing correctness is assumed — if parsing breaks, every calculation test will fail anyway. Only test actual calculations and formulas.

**When to split parameter vs calculation files:**
- Split into separate parameter and calculation files when the calculation has multiple cross-domain inputs worth validating individually (e.g., statues depend on artifacts, event shop, meritocracy, vault, talents)
- Use a single calculation file when the formula is trivially simple (e.g., `bonus * level`) with no meaningful cross-domain inputs to isolate

**Testing workflow:**
1. Get game code from developer
2. Create extraction config with individual components
Expand Down
98 changes: 98 additions & 0 deletions data/domain/data/ZenithMarketBonusRepo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { ZenithMarketBonusModel } from "../model/zenithMarketBonusModel";

export class ZenithMarketBonusBase { constructor(public index: number, public data: ZenithMarketBonusModel) { } }

export const initZenithMarketBonusRepo = (): ZenithMarketBonusBase[] => {
return [
new ZenithMarketBonusBase(0, <ZenithMarketBonusModel>{
name: "True Zen",
unlockCost: 1,
costExponent: 1.14,
maxLevel: 250,
bonus: 2,
x0: 1,
desc: "}x higher bonuses from Zenith Statues"
}),
new ZenithMarketBonusBase(1, <ZenithMarketBonusModel>{
name: "Kruk Bubbles",
unlockCost: 2,
costExponent: 6,
maxLevel: 5,
bonus: 1,
x0: 1,
desc: "Adds a new bubble for Kattlekruk to boost!"
}),
new ZenithMarketBonusBase(2, <ZenithMarketBonusModel>{
name: "Lamp Boost",
unlockCost: 5,
costExponent: 1.09,
maxLevel: 200,
bonus: 1,
x0: 1,
desc: "}x higher bonuses from The Lamp in Caverns"
}),
new ZenithMarketBonusBase(3, <ZenithMarketBonusModel>{
name: "Double Cluster",
unlockCost: 8,
costExponent: 1.17,
maxLevel: 100,
bonus: 5,
x0: 1,
desc: "+{% chance for a Double Zenith Cluster drop"
}),
new ZenithMarketBonusBase(4, <ZenithMarketBonusModel>{
name: "Bubble Boost",
unlockCost: 15,
costExponent: 1.5,
maxLevel: 25,
bonus: 2,
x0: 1,
desc: "+{ daily LVs for all Kattlekruk bubbles"
}),
new ZenithMarketBonusBase(5, <ZenithMarketBonusModel>{
name: "Super Dupers",
unlockCost: 50,
costExponent: 1.7,
maxLevel: 25,
bonus: 1,
x0: 1,
desc: "Super Talents get +{ more LVs"
}),
new ZenithMarketBonusBase(6, <ZenithMarketBonusModel>{
name: "Most Grandiose",
unlockCost: 250,
costExponent: 1.25,
maxLevel: 50,
bonus: 4,
x0: 1,
desc: "}x Grand Discovery Chance in Spelunking"
}),
new ZenithMarketBonusBase(7, <ZenithMarketBonusModel>{
name: "Giga Symbols",
unlockCost: 1000,
costExponent: 1.15,
maxLevel: 100,
bonus: 1,
x0: 1,
desc: "}x Sneaking Symbol success chance"
}),
new ZenithMarketBonusBase(8, <ZenithMarketBonusModel>{
name: "Woozle Wuzzle",
unlockCost: 5000,
costExponent: 1.125,
maxLevel: 30,
bonus: 1,
x0: 1,
desc: "+{% EXP Gain for the Research skill!"
}),
new ZenithMarketBonusBase(9, <ZenithMarketBonusModel>{
name: "Classy Gogo",
unlockCost: 25000,
costExponent: 1.115,
maxLevel: 100,
bonus: 1,
x0: 1,
desc: "}x Class EXP gain, for now..."
})
];
}
3 changes: 3 additions & 0 deletions data/domain/idleonData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { Hoops } from './world-1/hoops';
import { Darts } from './world-1/darts';
import { Orion, updateOrionFeatherRate, updateOrionGlobalBonus } from './world-1/orion';
import { Poppy, updatePoppyFishRate, updatePoppyGlobalBonus } from './world-2/poppy';
import { updateNonDepositedZenithClusters, ZenithMarket } from './world-7/zenithShop';

export const safeJsonParse = <T,>(doc: Cloudsave, key: string, emptyValue: T): T => {
const data = doc.get(key);
Expand Down Expand Up @@ -172,6 +173,7 @@ const domainList: Domain[] = [
new Darts("darts"),
new Orion("orion"),
new Poppy("poppy"),
new ZenithMarket("zenithMarket"),
]

export class IdleonData {
Expand Down Expand Up @@ -317,6 +319,7 @@ const postPostProcessingMap: Record<string, Function> = {
"emperorMaxAttempts": (doc: Cloudsave, accountData: Map<string, any>) => updateEmperorMaxAttempts(accountData),
"tesseractEfficiency": (doc: Cloudsave, accountData: Map<string, any>) => updateTesseractEfficiency(accountData),
"coralReefDailyGains": (doc: Cloudsave, accountData: Map<string, any>) => updateCoralReefDailyGain(accountData),
"nonDepositedZenithClusters": (doc: Cloudsave, accountData: Map<string, any>) => updateNonDepositedZenithClusters(accountData),
"alerts": (doc: Cloudsave, accountData: Map<string, any>) => updateAlerts(accountData),
}

Expand Down
9 changes: 9 additions & 0 deletions data/domain/model/zenithMarketBonusModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface ZenithMarketBonusModel {
name: string,
unlockCost: number,
costExponent: number,
maxLevel: number,
bonus: number,
x0: number,
desc: string,
}
75 changes: 63 additions & 12 deletions data/domain/world-1/statues.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { range, round } from '../../utility';
import { Domain, RawData } from '../base/domain';
import { initStatueRepo, StatueDataBase } from '../data/StatueRepo';
import { EventShop } from '../eventShop';
import { ImageData } from '../imageData';
import { Item } from '../items';
import { StatueDataModel } from '../model/statueDataModel';
import { Player } from '../player';
import { UpgradeVault } from '../upgradeVault';
import { Sailing } from '../world-5/sailing/sailing';
import { Meritocraty } from '../world-7/meritocraty';
import { ZenithMarket } from '../world-7/zenithShop';

export const StatueConst = {
LevelIndex: 0,
Expand All @@ -17,7 +21,8 @@ export const StatueConst = {
export enum StatusType {
Basic,
Gold,
Onyx
Onyx,
Zenith
}

export class Statue {
Expand All @@ -27,8 +32,10 @@ export class Statue {

public statueNumber: number = 0;

voodooStatuficationBonus: number = 1;
onyxStatueBonus: number = 2;
public onyxStatueBonus: number = 2;
public zenithStatueBonus: number = 1.5;
public dragonStatueBonus: number = 1;
public otherBonuses: number = 1;

constructor(public index: number, public displayName: string, public internalName: string, public bonus: string, public statueData: StatueDataModel) {
const StatueNumberRegex = /EquipmentStatues(\d+)/gm;
Expand Down Expand Up @@ -91,7 +98,8 @@ export class Statue {
default: talentBonus = 1;
}
}
return this.level * this.statueData.bonus * talentBonus * this.voodooStatuficationBonus * (this.type == StatusType.Onyx ? this.onyxStatueBonus : 1);
return this.level * this.statueData.bonus * talentBonus * (this.type >= StatusType.Onyx ? this.onyxStatueBonus : 1)
* (this.type >= StatusType.Zenith ? this.zenithStatueBonus : 1) * this.dragonStatueBonus * this.otherBonuses;
}

getBonusText = (player: Player | undefined = undefined) => {
Expand All @@ -113,6 +121,10 @@ export class Statue {
extraChar = "O";
break;
}
case StatusType.Zenith: {
extraChar = "Z";
break;
}
}
return {
location: `Statue${extraChar}${this.statueNumber}`,
Expand Down Expand Up @@ -165,11 +177,15 @@ export class Statues extends Domain {
if (goldStatues.length > statueIndex) {
switch (goldStatues[statueIndex]) {
case 1: {
statue.type = StatusType.Gold
statue.type = StatusType.Gold;
break;
}
case 2: {
statue.type = StatusType.Onyx
statue.type = StatusType.Onyx;
break;
}
case 3: {
statue.type = StatusType.Zenith;
break;
}
}
Expand All @@ -184,16 +200,51 @@ export const updateStatueBonuses = (data: Map<string, any>) => {
const statues = data.get("statues") as PlayerStatues[];
const playerData = data.get("players") as Player[];
const sailing = data.get("sailing") as Sailing;
const eventShop = data.get("eventShop") as EventShop;
const meritocraty = data.get("meritocraty") as Meritocraty;
const upgradeVault = data.get("upgradeVault") as UpgradeVault;
const zenithMarket = data.get("zenithMarket") as ZenithMarket;

let voodooStatuficationTalentBonus = 0;
const bestVoidMan = playerData.reduce((final, player) => final = (player.talents.find(talent => talent.skillIndex == 56)?.level ?? 0) > 0 && player.playerID > final.playerID ? player : final, playerData[0]);
if (bestVoidMan) {
statues.flatMap(player => player.statues).forEach(statue => {
statue.voodooStatuficationBonus = (1 + (bestVoidMan.talents.find(talent => talent.skillIndex == 56)?.getBonus() ?? 0) / 100);
})
voodooStatuficationTalentBonus = bestVoidMan.talents.find(talent => talent.skillIndex == 56)?.getBonus() ?? 0;
}
const eventShopBonus19 = eventShop.isBonusOwned(19) ? 30 : 0;
const meritocratyBonus26 = meritocraty.getCurrentBonus(26);
const otherBonuses = (1 + eventShopBonus19 / 100) * (1 + voodooStatuficationTalentBonus / 100) * (1 + meritocratyBonus26 / 100);

// Bonus for a few statues only
const upgradeVaultBonus = 1 + upgradeVault.getBonusForId(25) / 100;

const onyxStatueBonus = 2 + Math.max(0, sailing.artifacts[30].getBonus()) / 100;
statues.flatMap(player => player.statues).forEach(statue => {
statue.onyxStatueBonus = onyxStatueBonus;
})
const zenithStatueBonus = Math.max(1, 1 + (50 + zenithMarket.getBonusForId(0)) / 100);

statues.forEach(playerStatues => {
// First calculate the bonus for the dragon statue of the player
const dragonStatue = playerStatues.statues.find(statue => statue.index == 29);
let dragonStatueBonus = 1;
if (dragonStatue) {
dragonStatue.onyxStatueBonus = onyxStatueBonus;
dragonStatue.zenithStatueBonus = zenithStatueBonus;
dragonStatue.otherBonuses = otherBonuses;
dragonStatue.dragonStatueBonus = 1;
dragonStatueBonus = 1 + dragonStatue.getBonus() / 100;
}

// Then calculate the bonus for all other statues
playerStatues.statues.forEach(statue => {
if (statue.index != 29) {
statue.onyxStatueBonus = onyxStatueBonus;
statue.zenithStatueBonus = zenithStatueBonus;
// Add the upgrade vault bonus for those ones only
if([0, 1, 2, 6].includes(statue.index)) {
statue.otherBonuses = otherBonuses * upgradeVaultBonus;
} else {
statue.otherBonuses = otherBonuses;
}
statue.dragonStatueBonus = dragonStatueBonus;
}
});
});
}
2 changes: 1 addition & 1 deletion data/domain/world-4/tome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,7 @@ export const updateTomeScores = (data: Map<string, any>) => {
case 55:
// Number of Onyx statues
for (let i = 0; i < statues.length; i++) {
line.updatePlayerCurrentValue((statues[i].statues.filter(statue => statue && statue.type == StatusType.Onyx).length ?? 0), i);
line.updatePlayerCurrentValue((statues[i].statues.filter(statue => statue && statue.type >= StatusType.Onyx).length ?? 0), i);
}
break;
case 56:
Expand Down
6 changes: 5 additions & 1 deletion data/domain/world-5/hole/hole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Gambit } from "./gambit";
import { Tesseract } from "../../tesseract";
import { Jar } from "./jar";
import { LegendTalents } from "../../world-7/legendTalents";
import { ZenithMarket } from "../../world-7/zenithShop";

export class Villager {
level: number = 0;
Expand Down Expand Up @@ -660,7 +661,6 @@ export class Harp {
}

export class Lamp {
// TODO : update this once zenith market is added
zenithMarketBonus: number = 0;
wishes: Wish[] = [];
// This is hard-coded in the game, can move to the repo maybe.
Expand Down Expand Up @@ -919,6 +919,7 @@ export const updateHole = (data: Map<string, any>) => {
const deathnote = data.get("deathnote") as Deathnote;
const tesseract = data.get("tesseract") as Tesseract;
const legendTalents = data.get("legendTalents") as LegendTalents;
const zenithMarket = data.get("zenithMarket") as ZenithMarket;

// Update measurements with various cross domain data
hole.measurements.forEach(measurement => {
Expand All @@ -939,6 +940,9 @@ export const updateHole = (data: Map<string, any>) => {
bonus.legendTalentBonus = legendTalentBonus;
});

// Update the lamp bonuses from zenith market
hole.lamp.zenithMarketBonus = zenithMarket.getBonusForId(2);

// Update the gambit multiplier
let gambitMultipliers = (hole.measurements.find(measurement => measurement.index == 13)?.getBonus() ?? 0) + hole.getStudyBolaiaBonuses(13);
gambitMultipliers += tesseract.getUpgradeBonus(47) + (hole.monuments.monuments["Wisdom"].bonuses.find(bonus => bonus.index == 7)?.getBonus() ?? 0);
Expand Down
5 changes: 3 additions & 2 deletions data/domain/world-7/legendTalents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { LegendTalentModel } from "../model/legendTalentModel";
import { Player } from "../player";
import { Sailing } from "../world-5/sailing/sailing";
import { Clamworks } from "./clamworks";
import { ZenithMarket } from "./zenithShop";

export class LegendTalent {
level: number = 0;
Expand Down Expand Up @@ -104,6 +105,7 @@ export const updateLegendTalents = (data: Map<string, any>) => {
const sailing = data.get("sailing") as Sailing;
const clamworks = data.get("clamworks") as Clamworks;
const eventShop = data.get("eventShop") as EventShop;
const zenithMarket = data.get("zenithMarket") as ZenithMarket;

// Legend Talents Points
let pointsOwned = 0;
Expand All @@ -128,8 +130,7 @@ export const updateLegendTalents = (data: Map<string, any>) => {
// Super Talents
legendTalents.superTalentUnlocked = legendTalents.getBonusFromIndex(39) >= 1;
const legendTalentBonus7 = legendTalents.getBonusFromIndex(7);
// TODO : add zenith market bonus 5 here once implemented
const zenithMarketBonus5 = 0;
const zenithMarketBonus5 = zenithMarket.getBonusForId(5);
legendTalents.superTalentBonusLevels = Math.round(50 + legendTalentBonus7 + zenithMarketBonus5);

return legendTalents;
Expand Down
Loading
Loading