feat(smartstone): support account-wide unlocks (costumes, perks)#190
Conversation
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>
There was a problem hiding this comment.
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 destructuringfalse.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
- 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>
… 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>
Summary
mod-chromiecraft-smartstonerecently 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 (HandleSmartStoneUnlockAccountCommandinsrc/cs_smartstone.cpp) only acceptsACTION_TYPE_COSTUME(2) andACTION_TYPE_PERK(9); other service types fall through withLANG_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— addaddAccountVanity($accountName, $category, $vanityID)wrapping the new SOAP command. ExistingaddVanity()(per-character.smartstone unlock service) is untouched.Smartstone.php— introduce a single source of truthACCOUNT_WIDE_CATEGORIES = [2, 9]and anisAccountWide()helper, then branch each WooCommerce lifecycle hook on it:before_add_to_cart_button: render the 3D viewer only, skipFieldElements::charList()add_to_cart_validation: require login but skip theacore_char_selcheckadd_cart_item_data: stashacore_item_sku+unique_keywithoutacore_char_selget_item_data: render "Unlock for: Entire account" instead of a character rowadd_order_item_meta: writeacore_item_skuonlypayment_complete: resolve the buyer'suser_loginfrom the order's customer and calladdAccountVanity(); per-character categories still calladdVanity()Drive-by hardening
getItemId()previously returnedfalsefor non-smartstone SKUs but every call site did[$cat, $id] = self::getItemId(...), which destructuresfalseinto[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 ingetItemId()itself and castcategory/idtointonce 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 onin_array($category, [2, 9], true).When the mod expands the account-wide command
If
HandleSmartStoneUnlockAccountCommandever accepts more service types (mounts, vehicles, …), update the singleACCOUNT_WIDE_CATEGORIESconstant.Test plan
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_completefires.smartstone unlock service <char> 0 <id> true.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_completefires.smartstone unlock account <buyerLogin> 2 <id> true.smartstone_9_<perkId>) behaves identically to costumes.acore_auth.accountfor theiruser_login) gets an exception logged toacore_loginpayment_completefor account-wide SKUs (current behavior preserved — surfacing this earlier in validation is left as a follow-up).🤖 Generated with Claude Code