Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
89efbb2
feat(audit): classify scripted gameplay telemetry
zoeyrose Aug 14, 2026
aa691bc
style(audit): remove trailing blank line
zoeyrose Aug 14, 2026
0b993df
fix(audit): bind telemetry review to exact sites
zoeyrose Aug 14, 2026
a571e83
fix(audit): reject indirect logging calls
zoeyrose Aug 14, 2026
f6e10c2
fix(audit): reject reflective telemetry access
zoeyrose Aug 14, 2026
df1aa84
fix(audit): close telemetry reflection bypasses
zoeyrose Aug 14, 2026
0e43b81
fix(audit): bind reserved telemetry symbols
zoeyrose Aug 14, 2026
7f7e8ea
fix(audit): reserve reflective telemetry access
zoeyrose Aug 14, 2026
a891847
Merge branch 'main' into feat/160-scripted-audit-events
zoeyrose Aug 14, 2026
afb95d1
docs(audit): align quest inventory with lifecycle journal
zoeyrose Aug 14, 2026
2dba160
Merge branch 'main' into feat/160-scripted-audit-events
zoeyrose Aug 14, 2026
af8c3b2
docs(audit): align jail inventory with status publishing
zoeyrose Aug 14, 2026
88b7442
fix(quests): preserve retryable journal boundaries
zoeyrose Aug 14, 2026
42c7394
fix(audit): close indirect telemetry review gaps
zoeyrose Aug 14, 2026
2d7cf68
revert: defer quest replay to server contract
zoeyrose Aug 14, 2026
a0e1403
fix(audit): cover privileged execution boundaries
zoeyrose Aug 14, 2026
f626500
fix(audit): reject namespace callable aliases
zoeyrose Aug 14, 2026
cba4601
fix(audit): track privileged module aliases
zoeyrose Aug 14, 2026
23ebacd
fix(audit): track destructured module aliases
zoeyrose Aug 14, 2026
4783dd3
fix(audit): reject interpreter class aliases
zoeyrose Aug 14, 2026
82f013f
fix(audit): reject interpreter namespace reflection
zoeyrose Aug 14, 2026
fee21a7
fix(audit): inventory wildcard engine execution
zoeyrose Aug 14, 2026
efe5517
fix(audit): bind wildcard execution names
zoeyrose Aug 14, 2026
bcf0387
fix(audit): reject wildcard pattern bindings
zoeyrose Aug 14, 2026
a94358f
fix(audit): reject ambiguous wildcard imports
zoeyrose Aug 14, 2026
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
624 changes: 624 additions & 0 deletions contracts/scripted-gameplay-audit/v1.json

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions docs/SCRIPTED_GAMEPLAY_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Scripted gameplay audit boundaries

`contracts/scripted-gameplay-audit/v1.json` is the reviewed occurrence inventory
of every authored Python gameplay metric and audit-like logging call.
The aggregate validator parses all `maps/**/*.py` source except test fixtures,
rejects dynamic or indirect reserved telemetry access, inventories privileged
dynamic execution boundaries, and binds every call to its source,
lexical scope, AST location, and a normalized hash of the surrounding function
or module. Moving, adding, removing, or semantically surrounding a site
therefore requires an explicit noise, privacy, and recovery decision.

This is a governance check for trusted reviewed source, not a Python sandbox or
a proof over arbitrary metaprogramming. Authored telemetry must use the direct
forms represented by the contract; synthesizing it through imports, reflection,
namespaces, dynamic execution, or callable aliases is prohibited. The validator
rejects those reserved forms and fails closed when the direct-call inventory or
its surrounding context drifts. Runtime authorization and operator-console
security remain server responsibilities.
The telemetry spellings `Metric*`, `Logger`, `print`, and `log_add` are reserved
within authored maps; ambiguous shadowing, rebinding, or reflection is rejected.

The four dispositions describe the intended telemetry boundary:

- `gameplay-journal` is a bounded semantic transition useful for support or
recovery. Its proposed reason is ASCII, bounded to the Classic server's
255-character gameplay-journal identifier limit, and contains no player
text. The Classic server contract is authoritative; quest lifecycle producers
now use it, while economy producers remain gated on the composition API.
- `aggregate-only` retains bounded statistics without ordered event evidence.
- `operational/security-log` remains protected human/operator diagnostics.
- `not-recorded` is neither useful nor appropriate to retain.

All 26 current metric calls are low-volume quest, post, auction, merchant,
housing, bounty, guild, or jail outcomes classified as gameplay-journal
projections. Quest lifecycle producers now use the stable Classic contract.
This classification does not claim that the remaining legacy economy placement
is transactionally safe. Merchant purchase metrics currently
precede item or spell delivery, post collection precedes queue removal, and
housing metrics can follow debit while preceding the ownership or fee update.
They must move behind the durable idempotent commit/reconciliation result when
the typed APIs become available; generic payment or custody hooks must not add
a second copy of a business-specific aggregate.

The 21 current audit-like sites include generic Python diagnostics and prints,
the privileged `eval`, `exec`, engine callback, and interactive-console execution boundaries,
guild chat and console commands, guild-storage `Guild.log_add` calls, and their
human-text file sink. Some carry
display names or arbitrary operator/player text. That text must not enter
structured gameplay records. Successful storage custody instead belongs to the
server's typed item transaction at the authoritative post-veto move boundary.

High-volume movement, traversal, attacks, ordinary kills, damage, healing,
regeneration, routine spell/skill/consumable use, emotes, and every intermediate
quest-state write have no scripted metric sites and remain outside the
structured gameplay journal. Classified guild chat remains only in its
protected operational log. Adding a metric site requires a reviewed contract
row; it never becomes a journal producer merely because a metric is useful.

The stable quest contract from https://github.com/atrinik/classic/issues/161 is
integrated by the shared `QuestManager`. Crash-safe quest replay still requires
the server-owned idempotency and disposition contract tracked by
https://github.com/atrinik/classic/issues/321. Remaining executable economy
integration depends on the scripted multi-step composition contract in
https://github.com/atrinik/classic/issues/313. Content must use those typed APIs
rather than append raw logs, invent a second audit store, or misuse one journal
kind for unrelated item and currency flows.
78 changes: 78 additions & 0 deletions maps/python/tests/QuestManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ def journal_begin(*args):
mock.patch.object(
qm, "journal_commit", side_effect=commits.append):
qm.reset_quest()
qm.reset_quest()
self.assertFalse(qm.started())
self.assertEqual([
("quest.repeat-reset", "quest:repeat_failure_test_quest",
Expand Down Expand Up @@ -678,6 +679,83 @@ def test_14_kept_objective_item_is_retained_without_quest_flags(self):
self.assertFalse(sword.f_startequip)
sword.Destroy("test.quest-objective-cleanup")

def test_15_failed_journal_hooks_are_exactly_once(self):
quest = {
"parts": OrderedDict((("attempt", {
"info": "",
"uid": "attempt",
"name": "Attempt",
}),)),
"name": "Failed Journal Hook Quest",
"uid": "failed_journal_hook_quest",
}
qm = QuestManager(activator, quest)
qm.start("attempt")
intents = []
commits = []

def journal_begin(reason, subject, before, after, lineage=""):
transaction = "failure-{}".format(len(intents))
intents.append((reason, subject, before, after, lineage))
return transaction

with mock.patch.object(qm, "journal_begin", side_effect=journal_begin), \
mock.patch.object(
qm, "journal_commit", side_effect=commits.append):
self.assertTrue(qm.fail("attempt"))
self.assertFalse(qm.fail("attempt"))

self.assertEqual([
("quest.part-failed",
"quest-part:failed_journal_hook_quest::attempt",
Atrinik.QUEST_STATUS_STARTED, Atrinik.QUEST_STATUS_FAILED, ""),
("quest.failed", "quest:failed_journal_hook_quest",
Atrinik.QUEST_STATUS_STARTED, Atrinik.QUEST_STATUS_FAILED, ""),
], intents)
self.assertEqual(["failure-0", "failure-1"], commits)

def test_16_objective_removal_uses_one_typed_decrease_per_stack(self):
qm = object.__new__(QuestManager)
qm.activator = mock.Mock()
first = mock.Mock(nrof=2)
second = mock.Mock(nrof=5)
qm.activator.FindObjects.return_value = [first, second]
objective = mock.Mock(sub_type=Atrinik.QUEST_TYPE_ITEM)

qm.remove_quest_items({
"item": {
"arch": "sword",
"name": "objective sword",
"nrof": 4,
},
}, objective)

first.Decrease.assert_called_once_with(
2, reason="quest.objective-remove"
)
second.Decrease.assert_called_once_with(
2, reason="quest.objective-remove"
)

def test_17_rejected_intent_precedes_start_mutation(self):
quest = {
"parts": OrderedDict((("attempt", {
"info": "",
"uid": "attempt",
"name": "Attempt",
}),)),
"name": "Rejected Intent Quest",
"uid": "rejected_intent_quest",
}
qm = QuestManager(activator, quest)

with mock.patch.object(
qm, "journal_begin",
side_effect=RuntimeError("forced intent rejection")):
with self.assertRaisesRegex(RuntimeError, "forced intent"):
qm.start("attempt")

self.assertFalse(qm.started())

activator = Atrinik.WhoIsActivator()
me = Atrinik.WhoAmI()
Expand Down
Loading