Achievements are the points, badges and ranks a member collects for taking part. A core/AchievementAction extension is one thing a member can do that an administrator is then allowed to attach a rule to. "Member starts a topic", "member votes on a poll", "member's answer is marked as the best answer" and "member downloads a file" are all AchievementAction extensions; each one appears as an option in the drop-down at ACP → Members → Achievements → Rules → Add Rule, brings its own filter fields to that form, decides who gets the award when it happens, and supplies the wording shown in the member's points and badges log. If your application has an event worth rewarding, this extension is how you offer it to admins. Without it your event is simply not in the list, and nothing anywhere says why.
The extension is inert on its own. It is your own code that must call IPS\Member::achievementAction() at the moment the event happens; the extension only tells core what to do about it.
Where the abstract lives
This one is not in system/Extensions/. The base class is IPS\core\Achievements\Actions\AchievementActionAbstract, in applications/core/sources/Achievements/Actions/AchievementActionAbstract.php. Two further bases sit alongside it and are worth knowing before you write anything:
ContentAchievementActionAbstract(applications/core/sources/Achievements/Actions/ContentAchievementActionAbstract.php) — for actions performed on content items, comments or reviews. It gives you a "content type" filter, per-node filters, a milestone filter, anidentifier(), and arebuildData()that walks every routed content class for you. Threeprotected static boolswitches —$includeItems,$includeComments,$includeReviews(lines 44-46) — control which of those appear, and a fourth,$excludeItemsWithRequiredComment(line 51), lets items whose first comment is required back in when$includeItemsis off.NodeAchievementActionAbstract(same directory) — for actions performed on a node (a forum, a category), with a node-class filter.
Core's own 21 extensions are split between the three: seven — Comment, Review, NewContentItem, Reaction, Highlight, ContentPromotion and FollowContentItem — extend the content base, FollowNode extends the node base, and the remaining thirteen extend AchievementActionAbstract directly.
The contract
Five members are abstract and you must declare all five. Signatures below are copied from the source.
abstract public function identifier( Member $subject, mixed $extra = NULL ): string;
// A string that identifies THIS OCCURRENCE of the action. Written to
// core_achievements_log.identifier, which is VARCHAR(50) and carries a
// UNIQUE index on ( identifier, action ). The docblock says "must not
// exceed 32 chars". This is the whole de-duplication mechanism - see below.
abstract public function logRow( string $identifier, array $actor ): string;
// Human-readable line for the ACP logs. $identifier is whatever
// identifier() returned - possibly written by an older version of your
// app. $actor is explode( ',', $row['actor'] ), i.e. some of
// [ 'subject', 'other' ].
abstract public function ruleDescription( Rule $rule ): ?string;
// The sentence describing a rule on the ACP rules list. Printed RAW.
// May return NULL, which renders as an empty row title.
abstract public static function rebuildData(): array;
// STATIC. A list of tables to walk when an admin rebuilds achievements.
// Each entry: [ 'table' => ..., 'pkey' => ..., 'date' => ...|NULL,
// 'where' => [...] ], plus any extra keys your rebuildRow() wants.
abstract public static function rebuildRow( array $row, array $data ) : void;
// STATIC. Called once per row of the tables above. $data is the entry
// from rebuildData() that produced this row. Normally it just calls
// achievementAction() again for that row.
Everything else has a working default on the abstract and is optional:
public function canUse(): bool
// Default: Application::appIsEnabled( explode( '\\', get_called_class() )[1] ).
// ACP-only. Does NOT stop the action being processed at runtime.
public function showInAcp() : bool
// Default TRUE. Return FALSE to hide the action - and every existing rule
// that uses it - from the ACP rules list.
public function filters( ?array $filters, Url $url ): array
// Form fields for this action's filters, keyed by your own filter key.
// Call parent::filters() first; the parent adds the Quests filter.
public function formatFilterValues( array $values ): array
// Turn submitted form values into the array stored, JSON-encoded, in
// core_achievements_rules.filters. Call parent::formatFilterValues() first.
public function filtersMatch( Member $subject, array $filters, mixed $extra = NULL ): bool
// Default TRUE. Return FALSE to skip this rule for this occurrence.
public function awardOptions( ?array $filters ): array
// Default [ 'subject' => '' ]. Language keys labelling the two award
// boxes on the rule form. Add an 'other' key to offer a second award.
public function awardOther( mixed $extra = NULL, ?array $filters = NULL ): array
// Default []. Must return IPS\Member OBJECTS - the "other" people to
// award. Only called when the rule sets points_other or badge_other.
public function isRuleCompleted( Member $member, array $filters ) : bool
// Default FALSE. Retroactive completion, for Quests.
protected function _questFilterDescription( Rule $rule ): ?string
// Helper for ruleDescription(); returns NULL unless Quests is enabled.
The trigger you call from your own code is IPS\Member::achievementAction(), in system/Member/Member.php at line 6457:
public function achievementAction( string $app, string $extension, mixed $extra = NULL, DateTime $date = NULL ) : void
$extra is entirely yours. Whatever you pass is handed unchanged to identifier(), filtersMatch() and awardOther(), so those three must agree on its shape, and so must rebuildRow(). Core uses all sorts: a bare object (VotePoll receives an IPS\Poll), an array (DownloadFile receives [ 'file' => ..., 'downloader' => ... ]), or nothing at all (SessionStartDaily).
Registration is the usual data/extensions.json entry:
{
"core": {
"AchievementAction": {
"DownloadFile": "IPS\\downloads\\extensions\\core\\AchievementAction\\DownloadFile"
}
}
}
Who calls it
There is no single consumer. The methods are called from eight different places, and the split matters because a mistake in one is invisible from the others.
| Method | Called from |
|---|---|
identifier(), filtersMatch(), awardOther() | IPS\Member::achievementAction() — system/Member/Member.php lines 6484, 6514 and 6547. Front end, in the middle of whatever the member was doing. |
canUse(), filters(), awardOptions() | IPS\core\Achievements\Rule::form() — applications/core/sources/Achievements/Rule.php lines 165-220. The ACP Add/Edit Rule form, for every registered extension at once. |
formatFilterValues() | Rule::formatFormValues() — same file, line 255, when that form is saved. |
ruleDescription(), canUse(), awardOptions() | applications/core/dev/html/admin/achievements/rulesListRows.phtml lines 4 (canUse()), 12 (ruleDescription()) and 28-33 (awardOptions()), via Rule::extension() (Rule.php line 133, called on line 3); also applications/core/modules/admin/achievements/badges.php line 186 (ruleDescription() only). |
showInAcp() | applications/core/modules/admin/achievements/rules.php line 85. Actions returning FALSE are excluded from the rules list query. |
logRow() | Three ACP tables: applications/core/modules/admin/members/members.php line 5092 (a member's badge log), applications/core/extensions/core/MemberACPProfileBlocks/Points.php line 100 (a member's points log), applications/core/modules/admin/achievements/badges.php line 161 (a badge's award log). In each case the extension lookup is the line immediately above. |
rebuildData() | Rule::rebuildAllAchievements() — Rule.php line 430, triggered by ACP → Members → Achievements → Settings → Rebuild (applications/core/modules/admin/achievements/settings.php line 128). |
rebuildRow() | applications/core/extensions/core/Queue/RebuildAchievements.php line 157, once per row, from the background queue. |
isRuleCompleted() has no caller in the self-hosted source. Its docblock says "So far, this is only used in Quests", and Quests is gated behind Bridge::i()->featureIsEnabled( 'quests' ), which the self-hosted system/Platform/Bridge.php defaults to false (line 47). Implement it if you like; on a self-hosted site nothing will call it.
awardDescription( Rule $rule ): ?string exists on applications/core/extensions/core/AchievementAction/SessionStartDaily.php line 191 and looks like part of the contract. It is not. Grepping the whole suite finds no caller — the awardDescription used by the profile and widget templates is a different method, on IPS\core\Achievements\Badge (applications/core/sources/Achievements/Badge.php line 169), which reads a custom language string instead. Treat the extension method as vestigial.
The order of operations inside achievementAction()
Read Member.php lines 6457-6500 before you write identifier(). The sequence is not what most people assume:
canHaveAchievements()— FALSE ifachievements_enabledis off, if the member is a guest, or if their group is inrules_exclude_groups. Returns immediately.- Is there any rule at all for
"{$app}_{$extension}"inRule::getStore()? If not, return. If there are rules but none enabled, return. - Only now is your extension object loaded, and
identifier()called. - The log row is inserted before any filter is evaluated, with
Db::i()->insert( 'core_achievements_log', [...], FALSE, TRUE )— that finalTRUEisINSERT IGNORE. If the insert is ignored,$logIdis 0 and the method returns. - Then each enabled rule is tested with
filtersMatch(), and awards are worked out.
Two consequences follow from step 3 and step 4, and both are covered below.
Fatal error as soon as the extension list is built
Fatal error: Class IPS\acme\extensions\core\AchievementAction\Thing contains 5 abstract methods and must therefore be declared abstract or implement the remaining methods
Unlike most IPS extension types, this one has five abstract members, including two static ones that have nothing to do with the runtime behaviour you were interested in. You must declare identifier(), logRow(), ruleDescription(), rebuildData() and rebuildRow() even if two of them are stubs.
The fatal is a compile-time error raised by the autoloader inside class_exists() in Application::extensions() (system/Application/Application.php line 926), so it fires wherever the AchievementAction list specifically is built — the ACP achievements screens, the rebuild, and any front-end request that reaches line 6477 of achievementAction(), which is to say only once an enabled rule exists for that action. It is not "every page": nothing else enumerates this extension type.
The action never appears in the ACP list
Silent. No error, no log row.
Application::extensions() reads applications/<app>/data/extensions.json and nothing else; a class that is not in that file does not exist as far as the suite is concerned, and one whose fully-qualified name in the JSON does not resolve is skipped by if( !is_string( $classname ) or !class_exists( $classname ) ) { continue; }. That file is only rewritten by the Developer Center's Create/Delete Extension actions. Separately, the ACP rule form uses Application::allExtensions(), which caches the resolved list in the datastore under the key extensions; adding a file by hand clears nothing. Clear caches, or save the application record.
The other cause is showInAcp(). If you return FALSE from it, applications/core/modules/admin/achievements/rules.php line 97 excludes the action from the rules list query, so existing rules using it vanish from the ACP as well as the option to create new ones.
Rules save fine, then nothing is ever awarded — or the member's action fatals
This is the failure mode that costs the most time, and it comes from four different strings having to be identical.
Look at how a rule's action column is built, in Rule::form() (Rule.php lines 172-176):
$exploded = explode( '\\', get_class( $achievementAction ) );
$app = $exploded[1];
$extClass = $exploded[5];
$inputKey = implode( "_", [$app, $extClass] );
$options[ $inputKey ] = "AchievementAction__{$extClass}";
The stored action is <appDirectory>_<class basename>. But the lookup at runtime, in Member::achievementAction() line 6477, is:
$extensionObject = Application::load( $app )->extensions( 'core', 'AchievementAction' )[$extension];
…where $extension is the string you passed, and the array is keyed by the key in extensions.json. And the store lookup two lines earlier is keyed by "{$app}_{$extension}", which must equal the saved action. So:
- If the string you pass to
achievementAction()is not the class basename, the store lookup at line 6464 misses the rule the ACP saved, andachievementAction()returns having done nothing. Completely silent. - If the string you pass is the class basename but the extensions.json key is something else, you get past line 6464 and the array access at line 6477 hits a missing key.
$extensionObjectisNULL(with an "Undefined array key" warning), and the very next line calls$extensionObject->identifier(...). That is an uncaughtErrorin the middle of the member's request — the reply is not posted, the file is not downloaded. Note that it only happens once an admin has created an enabled rule, because until then the method returns at step 2. Your app tests clean and then breaks on a live site weeks later. - The rebuild uses a third derivation.
Rule::rebuildAllAchievements()(Rule.php lines 426-430) explodes the class name on\and takesarray_pop()of the result — the class basename again — as its queue key, andRebuildAchievementsthen looks that up in the extensions.json-keyed array at lines 61 and 125. Mismatch here means the rebuild for your action does nothing.
Make the file name, the class name, the extensions.json key and the second argument to achievementAction() the same word. Core does, for all twenty-one of its implementations.
An underscore in the key breaks everything downstream
The action string is split with explode( '_', ... ) in at least four places — Rule::extension() (line 135), Rule::formatFormValues() (line 247), RebuildAchievements::preQueueData() and ::run() (lines 60 and 122), and the three ACP log parsers — and every one of them takes element [0] as the application and element [1] as the extension key. An extension key of Best_Answer, or an application directory containing an underscore, therefore resolves to the wrong extension or to none at all. Nothing validates this. Use a single CamelCase word.
Filters silently do nothing, or the rule form is half-built
Both the abstract and the content base derive the current class's key with a hard-coded array index:
$classKey = explode( '\\', get_called_class() )[5];
(AchievementActionAbstract lines 74 and 98; ContentAchievementActionAbstract lines 62 and 166.) Index 5 is only correct for IPS\<app>\extensions\core\AchievementAction\<Key> — six segments exactly. A class placed at any other namespace depth produces an undefined-offset warning and a NULL key, and the field names it builds no longer match the ones formatFilterValues() looks for. Rule::form() makes the same assumption at line 174. Put the class exactly where the Developer Center puts it.
A second, subtler one: formatFilterValues() does not receive the whole form. Rule::formatFormValues() lines 250-255 pass only the fields named in Request::i()->activeFilters — the filters the admin actually switched on:
$filterValues = [];
foreach ( Request::i()->activeFilters ?: [] as $k => $v )
{
$filterValues[ $k ] = $values[ $k ];
}
$filters = $extension->formatFilterValues( $filterValues );
This is why every core implementation wraps each assignment in isset(). Reading $values['achievement_filter_...'] unguarded is an undefined-key warning on a perfectly ordinary save.
My exception in filters() broke the whole Add Rule form
Rule::form() loops over every registered AchievementAction and calls filters() and awardOptions() on each, with no try/catch anywhere in the loop (Rule.php lines 165-220). An exception, a missing node class, a Theme failure or a TypeError inside your filters() takes down the Add Rule form for every action on the site, and the stack trace names a core file. The same applies to the rules list, which calls ruleDescription() and awardOptions() straight from the template.
Two related shapes to get right. The array filters() returns must contain real form field objects — Rule::form() reads $filterElement->name and $filterElement->htmlId off each one at lines 184-190. And awardOptions() is indexed directly in rulesListRows.phtml lines 28-29 as $extension?->awardOptions( $rule->filters )['subject'] and ['other']; if an admin has set an "other" award on a rule whose extension does not offer an other key, that is an undefined-key warning on the shared rules list.
The award happens once and never again
This is the single most important thing to understand about identifier().
core_achievements_log carries a unique index on (identifier, action) — see applications/core/data/schema.json, table core_achievements_log, index lookup — and the insert is INSERT IGNORE. If the pair already exists, $logId is 0 and achievementAction() returns before evaluating a single rule:
$logId = Db::i()->insert( 'core_achievements_log', [
'action' => "{$app}_{$extension}",
'identifier' => $extensionObject->identifier( $this, $extra ),
'datetime' => ( $date ) ? $date->getTimestamp() : time()
], FALSE, TRUE );
if ( !$logId )
{
return;
}
The comment above it explains the intent: someone who unlikes and re-likes a post should not be paid twice. But the uniqueness is global to the action, not per member. If several members can perform your action on the same object, the identifier must include the member ID, or only the first member ever gets anything. Core's own implementations show the two shapes clearly — Reaction returns get_class( $extra['content'] ) . ':' . <id> . ':' . $extra['giver']->member_id, DownloadFile returns $extra['file']->id . '.' . $extra['downloader']->member_id, and Rsvp, Donation and Package all append $subject->member_id. VotePoll and JoinClub, by contrast, return only the poll or club ID, so under that unique index only one member per poll and one member per club can ever be logged.
Two further points about the same row. The log is written before filtersMatch() runs, so an occurrence that matches no rule still consumes its identifier permanently; loosening the rule's filters afterwards will not retro-award that occurrence, and only a full rebuild (which deletes core_achievements_log outright, Rule.php line 413) will. And the column is VARCHAR(50) while the docblock asks for 32 characters or fewer. Because the insert is INSERT IGNORE, MySQL downgrades over-length data to a truncation warning rather than an error, so a long identifier is quietly cut down — and two identifiers that differed only in their tail become one. Keep them short and put the variable part first.
Posting a reply now throws, and the trace points at core
Nothing in achievementAction() is wrapped in try/catch. identifier(), filtersMatch() and awardOther() all run inline in the request that triggered the action — and the callers of achievementAction() are core files: system/Content/Item.php line 612, system/Content/Hideable.php lines 695-703, system/Content/Reactable.php line 143, system/Poll/Poll.php line 463, system/Session/Front.php lines 222-380, system/Node/Model.php line 3734, and the REST and GraphQL controllers. An exception thrown from your extension therefore surfaces as a failure to post, to follow, to vote, or as a fatal on session start, with a trace that names IPS\Member::achievementAction().
The commonest sources are an $extra of an unexpected shape (your extension is asked about an occurrence queued by an older version of your code, or by a rebuild), and ::load() on something that has since been deleted. Guard both.
awardOther() is the one with a type trap: core immediately reads $member->member_id and, if the rule sets a badge, calls $member->badgeIds() on each element (Member.php lines 6551-6571). Returning member IDs rather than IPS\Member objects is not silently ignored, but the failure depends on the rule: with only points_other set you get PHP 8 "Attempt to read property on int" warnings and the points land under a NULL key, so nobody is paid; with badge_other set, line 6571 calls badgeIds() on an int and that is a fatal Error. awardOther() is only called when the rule has points_other or badge_other set, so a bug in it is invisible until an admin fills in the second award box.
The ACP badge log dies on one member's row
logRow() is called from three ACP tables, and they do not defend themselves equally. Points.php (lines 99-100) and badges.php (lines 160-161) wrap the lookup in catch( OutOfRangeException ) and fall back to a "rule deleted"/"unknown" string. members.php lines 5091-5092 do not:
else if ( isset( $exploded[1] ) )
{
$extension = Application::load( $exploded[0] )->extensions( 'core', 'AchievementAction' )[$exploded[1]];
return $extension->logRow( $row['identifier'], explode( ',', $row['actor'] ) );
}
So on ACP → Members → edit member → Badges, an uninstalled app produces an uncaught error on a page that has nothing to do with your app. And the two guarded tables are less guarded than they look: Application::load() throws OutOfRangeException for a missing app, but a missing extension key is only an array access on a key that is not there — a warning, then NULL, then NULL->logRow(), which is an Error and not an OutOfRangeException. Rename or remove an extension key while log rows still reference it and all three tables break. In all three, an exception from logRow() itself also escapes the parser.
Write logRow() defensively. It is handed identifiers that may be years old, so never assume the format is the one your current code produces, and never assume the referenced object still exists. DownloadFile::logRow() (lines 201-218) is the pattern: parse, try { File::load( ... ) } catch ( OutOfRangeException ) { /* "deleted" wording */ }. AnswerMarkedBest makes the same point from the other end: a comment in its identifier() (line 215) explains why the format was extended rather than changed: "Prevent existing rows from becoming obsolete by changing format".
ruleDescription() output is printed unescaped
rulesListRows.phtml line 12 is {$extension?->ruleDescription( $rule )|raw}. Anything you interpolate into that string — a node title, a member name, a custom field — is stored XSS in the AdminCP if you concatenate it yourself. Core never concatenates: every implementation builds the string with Member::loggedIn()->language()->addToStack( ..., FALSE, [ 'htmlsprintf' => [ Theme::i()->getTemplate( 'achievements', 'core' )->ruleDescriptionBadge( ... ) ] ] ) and returns the ruleDescription template. Returning NULL is legal and renders as a rule with no title, which is a good way to lose track of a rule.
The rebuild does nothing, or the queue jams
When an admin runs ACP → Members → Achievements → Settings → Rebuild, core wipes core_achievements_log, core_achievements_log_milestones, core_points_log and all rule-awarded badges, zeroes every member's points, and queues one RebuildAchievements job per AchievementAction extension in the suite (Rule.php lines 411-435). Your rebuildData() and rebuildRow() are the only reason your action's history comes back.
Failure here is quiet in one direction and loud in the other:
- Bad
rebuildData().RebuildAchievements::preQueueData()wraps its whole counting loop incatch( Exception $ex ) { return null; }(lines 58-83), and returnsnullagain if the count is zero. ReturningnullfrompreQueueData()means no queue row is created at all. The shipped extension template inapplications/core/data/defaults/extensions/AchievementAction.txtreturns[ [ ] ]— a single entry with every key commented out — so an extension built from the template and never finished rebuilds nothing, with no error and no queue row to look at. - Bad
rebuildRow(). Line 157-159 istry { $extensionObject::rebuildRow( $dbRow, $process ); } catch( Exception $e ) {}. AnExceptionis swallowed and that row is skipped in silence. A PHPError— aTypeError, a call onNULL— is not anException, escapes the queue runner, and leaves thequeuetask locked; the admin sees "Locked Task: queue" and every background job on the site stops.
Two details to get right in rebuildRow(). It is static, so $this is not available. And it re-enters achievementAction(), which means it must pass a $extra of exactly the shape your live call site passes — and should pass the historic date as the fourth argument if your table has one, or every rebuilt award will be dated today.
Milestone filters are off by one
Every core implementation carries the same warning in its filtersMatch() docblock, and it is worth repeating because the bug it describes is invisible in testing with small numbers:
Important note for milestones: consider the context. This method is called by \IPS\Member::achievementAction(). If your code calls that BEFORE making its change in the database (or there is read/write separation), you will need to add 1 to the value being considered for milestones
Milestone filters are implemented as a COUNT(*) — see VotePoll::filtersMatch(), lines 82-98, with the count on line 88 and the test if ( $count < $filters['milestone'] ) return FALSE;. If you fire achievementAction() before the row that represents the action is committed, the count is one short, and a "10th post" badge is awarded on the 11th. On a site with read/write separation it can be short even if you call it afterwards.
canUse() is not a runtime gate
Nothing in Member::achievementAction() calls canUse(). It is consulted only by the ACP rule form (Rule.php line 167) and the rules list template, where a FALSE result greys the rule out and shows acp_rule_disabled. The runtime lookup goes through Application::load( $app )->extensions( ... ), which does not check whether the application is enabled. Do not rely on canUse() to stop awards; if your action must not fire in some state, test for it in filtersMatch(), or simply do not call achievementAction().
Note also what canUse() does by default: Application::appIsEnabled( explode( '\\', get_called_class() )[1] ) — the same index-into-the-namespace assumption as everywhere else. Two of core's extensions override it (NewClub and JoinClub, which also require clubs to be switched on) and one in Nexus does (Subscription).
The action shows the wrong name in the ACP
The option in the rule form's drop-down is the language key "AchievementAction__{$extClass}" (Rule.php line 176), and the Developer Center's missing-strings scan looks for the same pattern — 'AchievementAction' => 'AchievementAction__{key}' in applications/core/modules/admin/developer/details.php line 566. The key does not include the application. If your extension key collides with one of core's — Comment, Review, Reaction, Highlight, NewPoll, VotePoll, NewClub, JoinClub, FollowNode, FollowMember, FollowContentItem, NewContentItem, ContentPromotion, ProfileCompletion, SessionStartDaily — the two rules remain functionally distinct (their action strings differ) but they show the same label, and whichever language pack loads last wins. Prefix your keys.
Following core's convention, an action wants at least these strings: AchievementAction__<Key> (the drop-down option, a sentence such as "Member votes on a poll"), AchievementAction__<Key>_title (the short label in the rule description), AchievementAction__<Key>_log (the log line), and, if you offer a milestone filter, AchievementAction__<Key>_title_generic plus the label and suffix keys your filter fields reference.
A complete example
The smallest useful implementation that exercises the whole contract. It rewards a member for a fictional acme_thanks row, awards the recipient as "other", and is rebuildable. Filters are omitted deliberately — they are optional, and the base class still supplies its own.
applications/acme/extensions/core/AchievementAction/Thanks.php:
<?php
namespace IPS\acme\extensions\core\AchievementAction;
use IPS\core\Achievements\Actions\AchievementActionAbstract;
use IPS\core\Achievements\Rule;
use IPS\DateTime;
use IPS\Member;
use IPS\Theme;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Thanks extends AchievementActionAbstract
{
/**
* Labels for the two award boxes on the rule form
*/
public function awardOptions( ?array $filters ): array
{
return [
'subject' => 'achievement_filter_Thanks_giver',
'other' => 'achievement_filter_Thanks_receiver'
];
}
/**
* The "other" people to award. MUST be Member objects.
*/
public function awardOther( mixed $extra = NULL, ?array $filters = NULL ): array
{
return [ $extra['receiver'] ];
}
/**
* Unique per occurrence. Includes BOTH members, or only the first
* pairing would ever be logged. Stays well under VARCHAR(50).
*/
public function identifier( Member $subject, mixed $extra = NULL ): string
{
return $extra['id'] . '.' . $extra['receiver']->member_id;
}
/**
* Must tolerate any identifier this app has ever written, and
* deleted records. Note: Member::load() does NOT throw for a
* missing member - it returns a guest Member - so the only thing
* that needs guarding here is the parse itself. A logRow() that
* loads a content object DOES need try/catch, because
* ActiveRecord::load() throws OutOfRangeException.
*/
public function logRow( string $identifier, array $actor ): string
{
$parts = explode( '.', $identifier );
$memberId = $parts[1] ?? 0;
$member = Member::load( $memberId );
return Member::loggedIn()->language()->addToStack(
in_array( 'other', $actor ) ? 'AchievementAction__Thanks_log_other'
: 'AchievementAction__Thanks_log_subject',
FALSE,
[ 'sprintf' => [ $member->name ] ]
);
}
/**
* Printed RAW on the ACP rules list - build it, do not concatenate it.
*/
public function ruleDescription( Rule $rule ): ?string
{
$conditions = [];
if ( $questCondition = $this->_questFilterDescription( $rule ) )
{
$conditions[] = $questCondition;
}
return Theme::i()->getTemplate( 'achievements', 'core' )->ruleDescription(
Member::loggedIn()->language()->addToStack( 'AchievementAction__Thanks_title' ),
$conditions
);
}
/**
* STATIC. Tables to walk when achievements are rebuilt.
*/
public static function rebuildData(): array
{
return [ [
'table' => 'acme_thanks',
'pkey' => 'thanks_id',
'date' => 'thanks_date',
'where' => [],
] ];
}
/**
* STATIC. One row at a time. Same $extra shape as the live call site,
* and pass the historic date so rebuilt awards are not dated today.
*/
public static function rebuildRow( array $row, array $data ) : void
{
Member::load( $row['thanks_giver'] )->achievementAction( 'acme', 'Thanks', [
'id' => $row['thanks_id'],
'receiver' => Member::load( $row['thanks_receiver'] )
], DateTime::ts( $row['thanks_date'] ) );
}
}
applications/acme/data/extensions.json — the key must match the class name:
{
"core": {
"AchievementAction": {
"Thanks": "IPS\\acme\\extensions\\core\\AchievementAction\\Thanks"
}
}
}
And the call site in your own code, after the row has been written, so that any milestone counting is correct:
$giver->achievementAction( 'acme', 'Thanks', [
'id' => $thanksId,
'receiver' => $receiver
] );
For a full-featured real implementation — node filters, milestone filters, a two-party award and a defensive logRow() — read applications/downloads/extensions/core/AchievementAction/DownloadFile.php end to end. It is 323 lines, it extends the plain abstract rather than a content base, and it is the closest thing in the suite to what a third-party app needs to write.
Verified against
Read from the source of Invision Community 5.0.19 (latest upgrade step applications/core/setup/upg_5001908). Key files: applications/core/sources/Achievements/Actions/AchievementActionAbstract.php, ContentAchievementActionAbstract.php, NodeAchievementActionAbstract.php, applications/core/sources/Achievements/Rule.php, Badge.php, system/Member/Member.php (achievementAction(), canHaveAchievements(), awardPoints()), system/Application/Application.php (extensions(), allExtensions(), constructExtensionClass()), applications/core/extensions/core/Queue/RebuildAchievements.php, applications/core/modules/admin/achievements/rules.php, badges.php, settings.php, applications/core/modules/admin/members/members.php, applications/core/extensions/core/MemberACPProfileBlocks/Points.php, applications/core/dev/html/admin/achievements/rulesListRows.phtml, applications/core/data/schema.json, applications/core/data/defaults/extensions/AchievementAction.txt, and all 21 implementations under applications/{core,forums,calendar,downloads,nexus}/extensions/core/AchievementAction/ (15 in core, 3 in nexus, 1 each in forums, calendar and downloads). Two things stated above are the absence of something rather than the presence of it, and are worth re-checking on your own version: no caller of isRuleCompleted() outside the Quests bridge, and no caller at all of the awardDescription() method on SessionStartDaily. Nothing here is carried over from IPS 4.x.
Recommended Comments