Skip to content

feat(smartstone): support account-wide unlocks (costumes, perks)#190

Merged
Helias merged 2 commits into
azerothcore:masterfrom
Nyeriah:feat/smartstone-account-wide-unlocks
May 23, 2026
Merged

feat(smartstone): support account-wide unlocks (costumes, perks)#190
Helias merged 2 commits into
azerothcore:masterfrom
Nyeriah:feat/smartstone-account-wide-unlocks

Conversation

@Nyeriah

@Nyeriah Nyeriah commented May 23, 2026

Copy link
Copy Markdown
Member

Summary

mod-chromiecraft-smartstone recently added a .smartstone unlock account <accountNameOrId> <serviceType> <id> <add> command that unlocks a service for an entire AC account rather than a single character. Server-side, the handler (HandleSmartStoneUnlockAccountCommand in src/cs_smartstone.cpp) only accepts ACTION_TYPE_COSTUME (2) and ACTION_TYPE_PERK (9); other service types fall through with LANG_MOD_UNKNOWN_SERVICE_TYPE.

This PR teaches the WooCommerce-side flow to use it. For SKUs in those categories the WP plugin no longer requires the buyer to select a character at any step — costumes and perks are inherently account-scoped on the server, so demanding a character pick was always vestigial.

Changes

  • SmartstoneService.php — add addAccountVanity($accountName, $category, $vanityID) wrapping the new SOAP command. Existing addVanity() (per-character .smartstone unlock service) is untouched.
  • Smartstone.php — introduce a single source of truth ACCOUNT_WIDE_CATEGORIES = [2, 9] and an isAccountWide() helper, then branch each WooCommerce lifecycle hook on it:
    • before_add_to_cart_button: render the 3D viewer only, skip FieldElements::charList()
    • add_to_cart_validation: require login but skip the acore_char_sel check
    • add_cart_item_data: stash acore_item_sku + unique_key without acore_char_sel
    • get_item_data: render "Unlock for: Entire account" instead of a character row
    • add_order_item_meta: write acore_item_sku only
    • payment_complete: resolve the buyer's user_login from the order's customer and call addAccountVanity(); per-character categories still call addVanity()

Drive-by hardening

getItemId() previously returned false for non-smartstone SKUs but every call site did [$cat, $id] = self::getItemId(...), which destructures false into [null, null] with a PHP notice. Replaced that pattern at all call sites with an explicit $parsed = ...; if (!$parsed) return; [$cat, $id] = $parsed;. Also guarded non-string / short SKUs in getItemId() itself and cast category/id to int once at the source so downstream strict comparisons work as expected.

Backwards compatibility

Per-character categories (Companion=0, Pet=1, any other value) keep their existing behavior. The addVanity() SOAP method is unchanged. New behavior is gated entirely on in_array($category, [2, 9], true).

When the mod expands the account-wide command

If HandleSmartStoneUnlockAccountCommand ever accepts more service types (mounts, vehicles, …), update the single ACCOUNT_WIDE_CATEGORIES constant.

Test plan

  • Per-character SKU (e.g. smartstone_0_<companionId>) on product page still shows the character selector; Add to cart with no character → "No character selected"; with a character → checkout shows "Character: X"; payment_complete fires .smartstone unlock service <char> 0 <id> true.
  • Account-wide costume SKU (smartstone_2_<costumeId>) on product page shows no character selector; Add to cart works without picking a character; cart/checkout shows "Unlock for: Entire account"; payment_complete fires .smartstone unlock account <buyerLogin> 2 <id> true.
  • Account-wide perk SKU (smartstone_9_<perkId>) behaves identically to costumes.
  • Logged-out user on any smartstone SKU still gets "You must be logged in to buy it!"
  • Buyer with no matching AC account (no row in acore_auth.account for their user_login) gets an exception logged to acore_log in payment_complete for account-wide SKUs (current behavior preserved — surfacing this earlier in validation is left as a follow-up).
  • Mixed cart (one per-character + one account-wide) checks out cleanly and fires both SOAP commands.

🤖 Generated with Claude Code

The mod-chromiecraft-smartstone module added a `.smartstone unlock account`
command that unlocks a service for an entire AC account rather than a single
character. Server-side it accepts ACTION_TYPE_COSTUME (2) and ACTION_TYPE_PERK
(9); other service types are rejected.

Plumb this through the WooCommerce flow so SKUs in those categories no longer
require a character selection at any step:

- SmartstoneService: add addAccountVanity() wrapping `.smartstone unlock
  account <accountName> <category> <id> true`.
- Smartstone hooks: introduce ACCOUNT_WIDE_CATEGORIES = [2, 9] and an
  isAccountWide() helper, then branch each lifecycle hook on it:
    * before_add_to_cart_button: render 3D viewer only, no FieldElements::charList
    * add_to_cart_validation: require login but skip the acore_char_sel check
    * add_cart_item_data: stash sku + unique_key without acore_char_sel
    * get_item_data: render "Unlock for: Entire account" instead of a character row
    * add_order_item_meta: write acore_item_sku only
    * payment_complete: resolve buyer's user_login from the order customer and
      call addAccountVanity(); per-character categories still call addVanity()
- Drive-by hardening in getItemId(): guard non-string / short SKUs, cast
  category and id to int once, and replace the unsafe `[$cat,$id] = false`
  destructure pattern at every call site with an explicit !$parsed check.

Per-character categories (Companion=0, Pet=1, anything else) keep their
existing behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the WooCommerce Smartstone purchase flow to support the server mod’s new account-wide unlock command for specific service types (costumes/perks), removing the vestigial character-selection requirement for those SKUs.

Changes:

  • Added a new SOAP wrapper method to call .smartstone unlock account … for account-wide unlocks.
  • Introduced ACCOUNT_WIDE_CATEGORIES + isAccountWide() and branched WooCommerce hooks to skip character selection and display “Entire account” for those categories.
  • Hardened SKU parsing (getItemId()) and updated call sites to avoid destructuring false.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/acore-wp-plugin/src/Manager/Soap/SmartstoneService.php Adds addAccountVanity() to execute the new account-wide unlock SOAP command.
src/acore-wp-plugin/src/Hooks/WooCommerce/Smartstone.php Routes account-wide categories through an account-based flow (no character selector), updates cart/order meta handling, and improves SKU parsing safety.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/acore-wp-plugin/src/Hooks/WooCommerce/Smartstone.php
Comment thread src/acore-wp-plugin/src/Manager/Soap/SmartstoneService.php
Comment on lines +243 to 258
if (self::isAccountWide($smartstone_category)) {
if (!$accountName) {
throw new \Exception("Smartstone account-wide unlock requires a buyer with a linked AC account; order $order_id has none.");
}
$res = $soap->addAccountVanity($accountName, $smartstone_category, $smartstone_id);
} else {
if (empty($item["acore_char_sel"])) {
throw new \Exception("Smartstone per-character unlock missing acore_char_sel on item in order $order_id.");
}
$charName = $WoWSrv->getCharName($item["acore_char_sel"]);
$res = $soap->addVanity($charName, $smartstone_category, $smartstone_id);
}

if ($res instanceof \Exception) {
throw $res;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — AcoreSoap::executeCommand() catches and returns $e->getMessage() (a string), so this instanceof \Exception branch is unreachable on SOAP failure. However this is a pre-existing bug on master, not something this PR introduces: the original payment_complete already had the same if ($res instanceof \Exception) throw $res; check, and the same pattern is present across every other service in src/Manager/Soap/ (CharacterService, AccountService, GuildService, etc.). Properly fixing it means refactoring the SOAP error-return shape across the whole plugin — clearly out of scope for an account-wide-unlock feature PR. I'd propose tracking it as a separate issue/PR ("normalize SOAP error returns to typed exceptions") and leaving the pattern consistent here so the future refactor is one mechanical sweep instead of two.

Comment thread src/acore-wp-plugin/src/Hooks/WooCommerce/Smartstone.php Outdated
- add_to_cart_validation: fail closed on login check for smartstone-prefixed
  SKUs before attempting to parse the rest of the SKU, so a misconfigured
  smartstone_* product can no longer bypass the login requirement when
  getItemId() rejects it.
- addAccountVanity: cast $category and $vanityID to int at the SOAP wrapper
  boundary as a belt-and-braces defense (the caller in payment_complete
  already passes ints from getItemId, but the public method should not
  rely on that). $accountName comes from sanitize_user-cleansed user_login.
- getItemId: tighten the prefix check to strict comparison (!==) for
  consistency with isAccountWide()'s strict in_array check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Helias Helias merged commit c9a6a57 into azerothcore:master May 23, 2026
1 of 2 checks passed
@Nyeriah Nyeriah deleted the feat/smartstone-account-wide-unlocks branch May 23, 2026 21:31
Nyeriah added a commit that referenced this pull request Jun 16, 2026
… at 1 (#194)

## Summary

Builds on #190 (account-wide smartstone unlocks) with two related UX
changes on the same WooCommerce flow.

### 1. Gifting (Costumes, Perks)

Lets buyers gift any account-wide smartstone product to another player
by entering a **character name** on the product page. The plugin
resolves it to that character's owning AC account and routes the SOAP
unlock there at payment time.

UX mirrors the existing item-gifting flow in `ItemSend.php` — buyers
never type an account name (AC account logins are semi-private and
unfriendly UX); they type a character name they don't need to own.

- **Every account-wide product page** renders a
`FieldElements::destCharacter` input below the 3D viewer with the label
*"Gift to a character (optional — leave blank to unlock for your own
account)"*. No per-product opt-in toggle.
- **Empty input** → buyer's own account unlocks (existing #190 behavior,
unchanged).
- **Recipient name supplied:**
- At `add_to_cart_validation`: looked up via `findOneByName`. Rejected
with a notice if the character doesn't exist, the character is banned,
or the recipient's account is banned.
- At `add_cart_item_data`: resolved to the recipient's account name and
stashed in cart_item_data as `acore_gift_account` (functional) +
`acore_gift_charname` (display).
- At `get_item_data`: cart line shows *"Gift for: \<character name\>"*
instead of *"Unlock for: Entire account"*.
- At `add_order_item_meta`: both gift fields persisted on the order
item.
- At `payment_complete`: `addAccountVanity()` is called with the gift
account instead of the buyer's.
- **Self-gift** (recipient character belongs to the buyer's own AC
account, case-insensitive compare): silently treated as a regular
self-unlock. No error notice — the gift fields are simply not stashed,
and the cart line falls back to the standard *"Unlock for: Entire
account"* display.
- **Per-character categories** (Companion=0, Pet=1, others) are
completely untouched.

### 2. Quantity cap

Every smartstone SKU is a one-shot unlock — per-character for
ACTION_TYPE_COMPANION (0) / ACTION_TYPE_PET (1), per-account for
ACTION_TYPE_COSTUME (2) / ACTION_TYPE_PERK (9). A quantity > 1 is
nonsensical: a second unlock for the same SKU on the same target is
either rejected by the mod (`LANG_MOD_SERVICE_ALREADY_UNLOCKED`) or a
silent no-op. The buyer just pays N times for the same thing.

- `woocommerce_quantity_input_args` filter: forces `min = max =
input_value = 1` on the product-page selector for any SKU starting with
`smartstone`. WC's `wc_quantity_input()` renders the field as plain text
"1" (no +/- buttons) when min == max.
- `woocommerce_cart_item_quantity` filter: returns a plain `"1"` string
for the cart-row quantity, making it non-editable after add-to-cart.

Multiple cart lines for the same SKU are still supported (e.g. gifting
the same costume to two different recipients) — the existing
`unique_key` in `add_cart_item_data` forces each add into its own line.
Using the `sold_individually` flag would block this, so it's
intentionally not used.

## Scope

- Only `src/acore-wp-plugin/src/Hooks/WooCommerce/Smartstone.php` is
touched (+104, −5).
- All repository methods used here (`findOneByName`, `findOneById`,
`getCharactersBannedRepo`, `getAccountBannedRepo`) are already used in
`CartValidation.php` and `FieldElements.php`.
- No new SOAP wrapper, no new SKU shape, no changes to the per-character
flow.

## Known limitation (deferred)

The original spec asked for an **at-add-to-cart "already unlocked"
check** for gifting, but the mod stores account-wide unlocks in
`acore_auth.smartstone_account_settings.data` as a serialized
`PlayerSettingVector` binary blob — and perks share a `settingId` slot
per class (all Druid perks → `settingId=110`), so a row-existence check
would incorrectly block buying a second Druid perk if the recipient owns
any. A proper duplicate check requires parsing the AC binary settings
format from PHP, which is out of scope here.

Current behavior on a duplicate gift: the SOAP server returns
`LANG_MOD_SERVICE_ALREADY_UNLOCKED`, `payment_complete` catches it and
writes it to the `acore_log` WooCommerce logger, and the buyer's
WooCommerce order still completes. Worth a follow-up issue.

## Test plan

### Gifting
- [ ] Account-wide product page (e.g. `smartstone_2_<costumeId>`)
renders the *"Gift to a character"* input field below the 3D viewer.
- [ ] Empty input → checkout completes as before, buyer's account gets
the unlock, cart shows *"Unlock for: Entire account"*.
- [ ] Recipient name of a character on **another** account: cart line
shows *"Gift for: \<charname\>"*; `payment_complete` fires `.smartstone
unlock account <recipient-account-login> <cat> <id> true`.
- [ ] Recipient name of a character on the **buyer's own** account:
silently behaves like empty input (no notice, no gift fields,
self-unlock path).
- [ ] Non-existent character name → *"Recipient character not found."*
notice; Add to cart fails.
- [ ] Banned character → *"Recipient character is banned."* notice; Add
to cart fails.
- [ ] Character on a banned account → *"Recipient account is banned."*
notice; Add to cart fails.
- [ ] Per-character products (`smartstone_0_*`, `smartstone_1_*`) are
unaffected — character selector still appears, validation still requires
`acore_char_sel`.
- [ ] Mixed cart (one gifted costume + one self-unlock perk + one
per-character pet) checks out cleanly and fires three distinct SOAP
commands with the right targets.

### Quantity cap
- [ ] Any smartstone product page renders quantity as plain text "1" (no
+/- buttons).
- [ ] Same product added to cart twice with different gift recipients
produces two cart lines, each locked at qty 1.
- [ ] Cart row qty for any smartstone line is non-editable text.
- [ ] Non-smartstone products' quantity selector is unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added an optional “Gift to a character” destination field for
account-wide smartstone unlocks during checkout.
* Checkout and order item details now display “Gift for” with the
recipient name when gifting; otherwise show “Unlock for: Entire
account.”

* **Bug Fixes / Improvements**
* Smartstone quantity is capped to `1`, and cart-row quantities for
smartstone items are no longer editable.
* Self-gifting is allowed, while ban checks are enforced for the
recipient when gifting.
* Account-wide unlock routing now targets the recipient’s linked account
when a destination character is provided.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants