Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ How it works:
|---|---|
| `MonOwnership` | `monsOwned` per-player set; ownership view + bulk-check helpers (`_isOwnerBatch`, `_validateOwnership`). Satisfies the `_isFacetMonOwned` hook on `Facets`. |
| `MonRegistry` | Owner-managed mon catalog (`monStats`, `monMoves`, `monAbilities`, `monMetadata`, sequential `monIds` set). Inherits `ITeamRegistry` so its `createMon`/`modifyMon`/batched-getters bind directly. Backs the `_getMonStatsForFacets` hook used by facet delta computation. |
| `PlayerProfile` | Packed `playerData[address]` (one uint256 per player) + the owner-managed `isAssigner` allowlist. Exposes `pointsBalance`, `isWhitelistedOpponent`, `isHardCpu`, the bulk admin flag setters, and `assignPoints` (IGachaPointsAssigner). |
| `PlayerProfile` | Packed `playerData[address]` (one uint256 per player) + the owner-managed `isAssigner` allowlist. Exposes `pointsBalance`, `isWhitelistedOpponent`, the bulk admin flag setters, and `assignPoints` (IGachaPointsAssigner). |
| `PackedTeamStore` | Bit-packed team CRUD: `teamGroupsPacked` (4 teams × 64 bits per slot), `teamOrderPacked` (16-slot live bitmap + display order). Constructor takes `MONS_PER_TEAM` / `MOVES_PER_MON` immutables. Subclass hooks: `_packedTeamValidateOwnership`, `_packedTeamIsCpuOpponent`, `_packedTeamGetMonData`. |
| `Facets` | 12-facet ±5% stat tradeoff system (see below). Pure helpers, packed per-mon facet data, `assignFacets`. |
| `MonExp` | Packed per-mon exp (`packedExpForMon`), level curve, level-up facet draws (`_processLevelUps`), public `getExp` / `getLevel` / `getExpAndLevelsFor*`, assigner-gated `assignExp`. Inherits `Facets` + `PackedTeamStore` because level-ups draw facets and team views need the lane helpers. Subclass hooks: `_assertExpAssigner`, `_monRegistrySize`. |
Expand All @@ -277,7 +277,7 @@ The leaf adds: the `onBattleEnd` orchestration (streak / quest / points / exp lo
playerData[address] (1 slot per player):
bit 255 bonusAwarded (first-game-ever bonus claimed)
bit 254 isWhitelistedAsOpponent (admin-set; replaces a separate mapping)
bit 253 isHardCpu (only meaningful when bit 254 is set)
bit 253 (reserved; formerly isHardCpu)
bits 250-252 streakDay (1..STREAK_FLAT_BONUS_MAX; 0 = no streak yet)
bits 224-249 (reserved)
bits 192-223 lastQuestCompletedDay (uint32 calendar day)
Expand Down Expand Up @@ -308,7 +308,7 @@ Both per-mon mappings share the same 16-mon bucketing so `_applyExpAndFacetDraws
- Streak flat: `streakDay` (1..5) added inside the parenthetical of both the points and per-mon-exp formulas. Only granted when the battle qualifies as "first of day" (≥24h since the last grant).
- Points formula: `(basePts + streakFlat) × gachaMult + firstGameEverBonus`. `gachaMult` is `×QUEST_REWARD_MULT` (= 2) when the active daily quest completes (winner-only, one-shot per day), else 1. `firstGameEverBonus` is `+FIRST_GAME_EVER_BONUS` (= 16), one-shot ever, applied *after* the multiplier.
- Per-mon exp formula: `(baseExp + streakFlat) × expMult`, capped at 65535. `baseExp` is `EXP_PER_SURVIVING_MON` (2) for alive slots, `EXP_PER_KOD_MON` (1) for KO'd slots.
- `expMult` stack: `PVP_EXP_MULT` (×2) on any PvP battle **xor** `HARD_CPU_EXP_MULT` (×2) on a battle against the Hard CPU (mutually exclusive — PvP wins the branch), times `QUEST_REWARD_MULT` (×2) on quest completion. Max stack = ×4.
- `expMult` stack: a flat `GAME_EXP_MULT` (×2) on **every** battle (the old PvP/Hard-CPU game-type bonuses were removed — hard-CPU difficulty was client-mutable and so nonsensical as a reward gate), times `QUEST_REWARD_MULT` (×2) on quest completion. Max stack = ×4. Points are **not** multiplied by the game mult — only by quest.
- Level-ups (12-tier curve, capped at level 12 to match `TOTAL_FACETS`) trigger one facet draw per level crossed.

**Facets.** 12 systematically-derived stat tradeoffs across 4 stat groups (`HP`, `Atk`, `Def`, `Speed`). `_facetDef(facetId)` is pure — no constant table. Magnitudes are **boost-indexed**: the boost stat determines both the boost% and the cost% paid on the nerfed stat. HP/Atk/Def boosts are symmetric (+5% / -5%); speed boosts pay a heavier cost (+5% / -10%) so they can't cheaply break speed ties. The percentages live as `BOOST_PCT_*` / `COST_PCT_*` constants in `Facets.sol`. Unlocks are persistent per-mon; `assignFacets(monIds, facetIds)` is a free bulk re-assign that requires the caller to own every listed mon and the facet to be in the unlocked bitmap (`facetId == 0` clears). `GachaTeamRegistry.getTeams()` folds the active facet's delta into each mon's stats before returning, so by the time the Engine stores teams in `BattleConfig` they already reflect the boost/cost. `validateMon` no longer checks stat equality (the round-trip would always fail with facets applied) — moves and ability membership are still enforced.
Expand All @@ -320,7 +320,7 @@ Both per-mon mappings share the same 16-mon bucketing so `_applyExpAndFacetDraws
**Events.**

- `Roll(address indexed player, uint256[] monIds, uint256 pointsSpent)` — fires on both `firstRoll` (spend = 0) and paid `roll`.
- `GachaEvent(bytes32 indexed battleKey, uint256 p0Packed, uint256 p1Packed)` — one per battle, carrying both sides' packed payloads (CPU side is 0). Layout sized for `MONS_PER_TEAM` up to 8: points (bits 0-15), per-mon exp gain (bits 16-79, 8 lanes × 8b), per-mon facets unlocked this battle (bits 80-175, 8 lanes × 12-bit bitmap = 1 bit per facet id), `BONUS_*` flags (bits 176-183: `FIRST_ROLL` | `FIRST_GAME` | `HARD_CPU` | `QUEST`), combined exp multiplier (bits 184-191), outcome (bits 192-199: 0=loss, 1=win, 2=draw), `streakDay` (bits 200-202). Lanes saturate so a future tuning blow-up can't bleed into neighbouring fields.
- `GachaEvent(bytes32 indexed battleKey, uint256 p0Packed, uint256 p1Packed)` — one per battle, carrying both sides' packed payloads (CPU side is 0). Layout sized for `MONS_PER_TEAM` up to 8: points (bits 0-15), per-mon exp gain (bits 16-79, 8 lanes × 8b), per-mon facets unlocked this battle (bits 80-175, 8 lanes × 12-bit bitmap = 1 bit per facet id), `BONUS_*` flags (bits 176-183: `FIRST_ROLL` | `FIRST_GAME` | _bit 2 reserved, formerly `HARD_CPU`_ | `QUEST`), combined exp multiplier (bits 184-191; now always ≥2 from the flat game mult), outcome (bits 192-199: 0=loss, 1=win, 2=draw), `streakDay` (bits 200-202). Lanes saturate so a future tuning blow-up can't bleed into neighbouring fields.

### Storage Architecture

Expand Down
8 changes: 0 additions & 8 deletions processing/generateSetupCPU.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ def load_cpu_teams(json_path: Path) -> dict[str, Any]:

def render_solidity(data: dict[str, Any], mon_names: dict[int, str]) -> str:
cpu_players: list[str] = data["cpuPlayers"]
hard_cpus: list[str] = data.get("hardCpus", [])
teams: list[list[int]] = data["teams"]

lines: list[str] = []
Expand Down Expand Up @@ -86,13 +85,6 @@ def render_solidity(data: dict[str, Any], mon_names: dict[int, str]) -> str:
lines.append(" }")
lines.append(" gachaTeamRegistry.setWhitelistedOpponents(cpuAddresses, empty);")

if hard_cpus:
lines.append("")
lines.append(f" address[] memory hardCpus = new address[]({len(hard_cpus)});")
for i, name in enumerate(hard_cpus):
lines.append(f' hardCpus[{i}] = vm.envAddress("{name}");')
lines.append(" gachaTeamRegistry.setHardCpuOpponents(hardCpus, empty);")

lines.append("")
lines.append(" vm.stopBroadcast();")
lines.append("")
Expand Down
8 changes: 4 additions & 4 deletions src/Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ uint256 constant GACHA_FIRST_GAME_EVER_BONUS = 16;
uint256 constant EXP_PER_SURVIVING_MON = 2;
uint256 constant EXP_PER_KOD_MON = 1;

// Always-on exp multipliers (game-type bonuses)
uint256 constant PVP_EXP_MULT = 2;
uint256 constant HARD_CPU_EXP_MULT = 2;
// Flat exp multiplier applied to every game's per-mon exp. Replaces the old
// game-type bonuses (hard-CPU difficulty was client-mutable and PvP/CPU no longer differ).
uint256 constant GAME_EXP_MULT = 2;

// First-game-of-the-day flat bonus that ratchets with a daily-login streak.
// Added to base reward *before* multipliers so it rides quest / PvP / hard-CPU mults.
// Added to base reward *before* multipliers so it rides the game + quest mults.
// Streak resets to 1 once the gap since the last bonus exceeds STREAK_GRACE_WINDOW.
uint256 constant STREAK_FLAT_BONUS_MAX = 5;
uint256 constant STREAK_GRACE_WINDOW = 36 hours;
Expand Down
1 change: 0 additions & 1 deletion src/Structs.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ struct CustomBattleProposal {
uint96 p0TeamIndex;
uint256[] monIndices;
uint8[] facetIds;
bool isHard; // hard-CPU difficulty flag for the phantom config (client-set; drives ×2 exp)
ITeamRegistry teamRegistry;
IValidator validator;
IRandomnessOracle rngOracle;
Expand Down
2 changes: 1 addition & 1 deletion src/cpu/CPU.sol
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ abstract contract CPU is CPUMoveManager, ICPU, ICPURNG, IMatchmaker {
/// to other users' slots. The registry must implement IPhantomTeamRegistry; the relay gate
/// enforces that only whitelisted CPUs (i.e. this contract once added) can land the write.
function startCustomBattle(CustomBattleProposal calldata p) external returns (bytes32 battleKey) {
IPhantomTeamRegistry(address(p.teamRegistry)).setOpponentTeamFor(p.p0, p.monIndices, p.facetIds, p.isHard);
IPhantomTeamRegistry(address(p.teamRegistry)).setOpponentTeamFor(p.p0, p.monIndices, p.facetIds);

uint96 p1TeamIndex = uint96(uint16(uint160(p.p0)));
(battleKey,) = ENGINE.computeBattleKey(p.p0, address(this));
Expand Down
51 changes: 11 additions & 40 deletions src/game-layer/GachaTeamRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import {
GACHA_FIRST_GAME_EVER_BONUS,
EXP_PER_SURVIVING_MON,
EXP_PER_KOD_MON,
PVP_EXP_MULT,
HARD_CPU_EXP_MULT,
GAME_EXP_MULT,
STREAK_FLAT_BONUS_MAX,
STREAK_GRACE_WINDOW,
QUEST_REWARD_MULT,
Expand Down Expand Up @@ -88,7 +87,7 @@ contract GachaTeamRegistry is

uint256 internal constant BONUS_FIRST_ROLL = 1 << 0;
uint256 internal constant BONUS_FIRST_GAME = 1 << 1;
uint256 internal constant BONUS_HARD_CPU = 1 << 2;
// bit 2 (formerly BONUS_HARD_CPU) is reserved/unused: hard-CPU difficulty was removed.
uint256 internal constant BONUS_QUEST = 1 << 3;

// ----- Errors -----
Expand Down Expand Up @@ -123,12 +122,6 @@ contract GachaTeamRegistry is
// resolves both the team's mon ids and its facet config at battle start.
uint256 internal constant OPP_FACET_BITS_PER_SLOT = 4;
uint256 internal constant OPP_FACET_SLOT_MASK = (1 << OPP_FACET_BITS_PER_SLOT) - 1;
// Hard-CPU difficulty flag inside the per-(user, CPU) phantom config slot
// (opponentTeamFacetsPacked). High bit, far above the facet lanes (<= 8 mons x 4 bits = 32 bits).
// Client-set via setOpponentTeam/For: once the CPU runs off-chain (and the CPU contracts collapse
// to one address) difficulty is a client concept, so the hard-CPU x2 exp keys off this bit rather
// than the opponent's profile IS_HARD_CPU_BIT. Trust-the-client (see PLAN_OFFCHAIN_CPU.md).
uint256 internal constant OPP_HARD_CPU_BIT = 1 << 255;
mapping(address opponent => mapping(uint256 phantomKey => uint256 packedFacets)) public opponentTeamFacetsPacked;

constructor(
Expand Down Expand Up @@ -260,11 +253,10 @@ contract GachaTeamRegistry is
function setOpponentTeam(
address opponent,
uint256[] memory monIndices,
uint8[] memory facetIds,
bool isHard
uint8[] memory facetIds
) external {
if (!isWhitelistedOpponent(opponent)) revert NotWhitelistedOpponent();
_setOpponentTeam(opponent, msg.sender, monIndices, facetIds, isHard);
_setOpponentTeam(opponent, msg.sender, monIndices, facetIds);
}

/// @notice Trusted-relayer entry: a whitelisted CPU writes a user's phantom team
Expand All @@ -273,19 +265,17 @@ contract GachaTeamRegistry is
function setOpponentTeamFor(
address user,
uint256[] memory monIndices,
uint8[] memory facetIds,
bool isHard
uint8[] memory facetIds
) external override {
if (!isWhitelistedOpponent(msg.sender)) revert NotWhitelistedOpponent();
_setOpponentTeam(msg.sender, user, monIndices, facetIds, isHard);
_setOpponentTeam(msg.sender, user, monIndices, facetIds);
}

function _setOpponentTeam(
address opponent,
address user,
uint256[] memory monIndices,
uint8[] memory facetIds,
bool isHard
uint8[] memory facetIds
) internal {
if (monIndices.length != facetIds.length) revert FacetArgsLengthMismatch();
uint256 phantomKey = uint16(uint160(user));
Expand All @@ -298,9 +288,6 @@ contract GachaTeamRegistry is
packedFacets |= uint256(facetId) << (i * OPP_FACET_BITS_PER_SLOT);
unchecked { ++i; }
}
// Difficulty rides in the same slot (high bit), client-set. Whole slot is rewritten each
// call, so the flag must be (re)applied here.
if (isHard) packedFacets |= OPP_HARD_CPU_BIT;
opponentTeamFacetsPacked[opponent][phantomKey] = packedFacets;
}

Expand Down Expand Up @@ -534,7 +521,6 @@ contract GachaTeamRegistry is
uint256 packed1 = playerData[ctx.p1];
bool isCpu0 = packed0 & IS_CPU_BIT != 0;
bool isCpu1 = packed1 & IS_CPU_BIT != 0;
bool isPvP = !(isCpu0 || isCpu1);

uint256[2] memory packedEvents;

Expand All @@ -547,17 +533,8 @@ contract GachaTeamRegistry is
uint8 koBitmap = playerIndex == 0 ? ctx.p0KOBitmap : ctx.p1KOBitmap;
uint256 basePts = ctx.winner == player ? POINTS_PER_WIN : POINTS_PER_LOSS;
uint256 packed = playerIndex == 0 ? packed0 : packed1;
// Hard-CPU x2 exp keys off the opponent's per-(user, CPU) phantom config bit (client-set
// via setOpponentTeam), not the opponent's profile flag: once the CPU runs off-chain and
// the CPU contracts collapse to one address, difficulty is a client concept. The CPU's
// battle team index IS this user's phantom key, so the phantom slot reads back here.
bool oppIsCpu = playerIndex == 0 ? isCpu1 : isCpu0;
address oppAddr = playerIndex == 0 ? ctx.p1 : ctx.p0;
uint256 oppTeamIdx = playerIndex == 0 ? ctx.p1TeamIndex : ctx.p0TeamIndex;
bool oppIsHardCpu =
oppIsCpu && (opponentTeamFacetsPacked[oppAddr][oppTeamIdx] & OPP_HARD_CPU_BIT) != 0;

uint256 preservedFlags = packed & (BONUS_AWARDED_BIT | IS_CPU_BIT | IS_HARD_CPU_BIT);

uint256 preservedFlags = packed & (BONUS_AWARDED_BIT | IS_CPU_BIT);
uint256 points = packed & POINTS_MASK_128;
uint32 lastFirstGameTs = uint32(packed >> LAST_FIRST_GAME_TS_SHIFT);
uint32 lastSeenTs = uint32(packed >> LAST_SEEN_TS_SHIFT);
Expand All @@ -566,16 +543,10 @@ contract GachaTeamRegistry is

uint256 bonusFlags;
uint256 streakFlat;
uint256 expMult = 1;
// Flat 2x exp on every game (game-type bonuses removed); quest can still stack on top.
uint256 expMult = GAME_EXP_MULT;
uint256 gachaMult = 1;

if (isPvP) {
expMult *= PVP_EXP_MULT;
} else if (oppIsHardCpu) {
expMult *= HARD_CPU_EXP_MULT;
bonusFlags |= BONUS_HARD_CPU;
}

// The rolling 24h cooldown (measured from the last bonus-earning game) gates the
// streak bonus to once per day. The 36h grace decides ratchet-vs-reset, but is
// measured from the last battle of ANY kind (lastSeenTs) rather than the last
Expand Down
2 changes: 1 addition & 1 deletion src/game-layer/IPhantomTeamRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ pragma solidity ^0.8.0;
/// caller (a whitelisted CPU). Lets a matchmaker bundle team-config + battle-start
/// in one tx while preserving per-user phantom-slot isolation.
interface IPhantomTeamRegistry {
function setOpponentTeamFor(address user, uint256[] memory monIndices, uint8[] memory facetIds, bool isHard)
function setOpponentTeamFor(address user, uint256[] memory monIndices, uint8[] memory facetIds)
external;
}
12 changes: 2 additions & 10 deletions src/game-layer/PlayerProfile.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {IGachaPointsAssigner} from "./IGachaPointsAssigner.sol";
/// `playerData[address]` bit layout:
/// bit 255 : bonusAwarded (first-game-ever bonus has been awarded)
/// bit 254 : isWhitelistedAsOpponent (CPU flag)
/// bit 253 : isHardCpu (only meaningful when bit 254 is set)
/// bit 253 : (reserved; formerly isHardCpu)
/// bits 250-252 : streakDay (1..STREAK_FLAT_BONUS_MAX; 0 = no streak yet)
/// bits 224-249 : (reserved)
/// bits 192-223 : lastQuestCompletedDay (uint32 calendar day)
Expand All @@ -24,7 +24,7 @@ abstract contract PlayerProfile is IGachaPointsAssigner, Ownable {

uint256 internal constant BONUS_AWARDED_BIT = 1 << 255;
uint256 internal constant IS_CPU_BIT = 1 << 254;
uint256 internal constant IS_HARD_CPU_BIT = 1 << 253;
// bit 253 reserved (formerly IS_HARD_CPU_BIT)
uint256 internal constant STREAK_DAY_SHIFT = 250;
uint256 internal constant STREAK_DAY_MASK = 0x7;
uint256 internal constant LAST_FIRST_GAME_TS_SHIFT = 128;
Expand All @@ -44,10 +44,6 @@ abstract contract PlayerProfile is IGachaPointsAssigner, Ownable {
_flipPlayerDataBitBulk(toAllow, toDisallow, IS_CPU_BIT);
}

function setHardCpuOpponents(address[] memory toMark, address[] memory toUnmark) external onlyOwner {
_flipPlayerDataBitBulk(toMark, toUnmark, IS_HARD_CPU_BIT);
}

function setAssigners(address[] memory toAdd, address[] memory toRemove) external onlyOwner {
for (uint256 i; i < toAdd.length;) {
isAssigner[toAdd[i]] = true;
Expand Down Expand Up @@ -80,10 +76,6 @@ abstract contract PlayerProfile is IGachaPointsAssigner, Ownable {
return playerData[addr] & IS_CPU_BIT != 0;
}

function isHardCpu(address addr) public view returns (bool) {
return playerData[addr] & IS_HARD_CPU_BIT != 0;
}

// ----- IGachaPointsAssigner -----

function assignPoints(address player, uint256 amount) external override {
Expand Down
2 changes: 1 addition & 1 deletion test/GachaFacetsBattleTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ contract GachaFacetsBattleTest is Test {
cpuFacets[0] = 1;
cpuFacets[1] = 0;
vm.prank(ALICE);
registry.setOpponentTeam(CPU, cpuMons, cpuFacets, false);
registry.setOpponentTeam(CPU, cpuMons, cpuFacets);

// Both players must authorize the matchmaker with the engine.
address[] memory makersToAdd = new address[](1);
Expand Down
2 changes: 0 additions & 2 deletions test/GachaMigrationTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ contract GachaMigrationTest is Test {
address[] memory aliceList = new address[](1);
aliceList[0] = ALICE;
oldReg.setWhitelistedOpponents(aliceList, new address[](0));
oldReg.setHardCpuOpponents(aliceList, new address[](0));

// ----- New (destination) registry: points back at oldReg. -----
newReg = new GachaTeamRegistry(MONS_PER_TEAM, MOVES_PER_MON, engine, mockRNG, oldReg);
Expand Down Expand Up @@ -114,7 +113,6 @@ contract GachaMigrationTest is Test {
assertEq(newReg.playerData(ALICE), oldReg.playerData(ALICE), "profile word verbatim");
assertEq(newReg.pointsBalance(ALICE), 1234, "points carried");
assertTrue(newReg.isWhitelistedOpponent(ALICE), "whitelist flag carried");
assertTrue(newReg.isHardCpu(ALICE), "hard-cpu flag carried");
}

function test_migrate_copiesExpAndFacets() public {
Expand Down
Loading
Loading