feat(phase0): complete shared contracts and milestone plan
This commit is contained in:
+4
-4
@@ -18,12 +18,12 @@
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"from": "parameters.activity",
|
||||
"to": "state.energy"
|
||||
"source": "parameters.activity",
|
||||
"target": "state.energy"
|
||||
},
|
||||
{
|
||||
"from": "state.energy",
|
||||
"to": "parameters.activity"
|
||||
"source": "state.energy",
|
||||
"target": "parameters.activity"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+11
-7
@@ -18,13 +18,17 @@
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"from": "parameters.activity",
|
||||
"to": "state.energy",
|
||||
"transform": {
|
||||
"op": "add",
|
||||
"args": [
|
||||
1.0
|
||||
]
|
||||
"source": "parameters.activity",
|
||||
"target": "state.energy",
|
||||
"when": {
|
||||
"op": "gt",
|
||||
"left": {
|
||||
"op": "add",
|
||||
"args": [
|
||||
1.0
|
||||
]
|
||||
},
|
||||
"right": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"from": "parameters.non_existent_param",
|
||||
"to": "parameters.activity"
|
||||
"source": "parameters.non_existent_param",
|
||||
"target": "parameters.activity"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-9
@@ -48,15 +48,9 @@
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"from": "parameters.activity",
|
||||
"to": "state.energy",
|
||||
"transform": {
|
||||
"op": "multiply",
|
||||
"args": [
|
||||
{ "ref": "parameters.activity" },
|
||||
1.5
|
||||
]
|
||||
},
|
||||
"source": "parameters.activity",
|
||||
"target": "state.energy",
|
||||
"scale": 1.5,
|
||||
"smoothing": "250ms"
|
||||
}
|
||||
]
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"tickSeconds": 0.016666666666666666,
|
||||
"traces": {
|
||||
"directBusOverride": [
|
||||
{ "tick": 0, "storedActivity": 0.4, "binding": 0.4, "override": 0.8, "resolved": 0.8 },
|
||||
{ "tick": 1, "storedActivity": 0.2, "binding": 0.2, "override": 0.8, "resolved": 0.8 },
|
||||
{ "tick": 2, "storedActivity": 0.2, "binding": 0.2, "releaseProgress": 0.5, "resolved": 0.5 },
|
||||
{ "tick": 3, "storedActivity": 0.2, "binding": 0.2, "releaseProgress": 1, "resolved": 0.2 }
|
||||
],
|
||||
"parameterOverrideFeedsBinding": [
|
||||
{ "tick": 0, "storedActivity": 0.3, "activityOverride": 0.9, "resolvedActivity": 0.9, "busGain": 0.95 },
|
||||
{ "tick": 1, "storedActivity": 0.2, "activityOverride": 0.9, "resolvedActivity": 0.9, "busGain": 0.95 },
|
||||
{ "tick": 2, "storedActivity": 0.2, "releaseProgress": 0.5, "resolvedActivity": 0.55, "busGain": 0.775 },
|
||||
{ "tick": 3, "storedActivity": 0.2, "releaseProgress": 1, "resolvedActivity": 0.2, "busGain": 0.6 }
|
||||
],
|
||||
"automationMaskedThenReleased": [
|
||||
{ "tick": 0, "automation": 0.25, "override": 0.75, "resolved": 0.75 },
|
||||
{ "tick": 1, "automation": 0.45, "override": 0.75, "resolved": 0.75 },
|
||||
{ "tick": 2, "automation": 0.65, "releaseProgress": 0.5, "resolved": 0.7 },
|
||||
{ "tick": 3, "automation": 0.85, "releaseProgress": 1, "resolved": 0.85 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{ "tick": 1, "low": 0.1, "mid": 0.2, "high": 0.3, "energy": 0.2 },
|
||||
{ "tick": 2, "low": 0.8, "mid": 0.4, "high": 0.1, "energy": 0.55 },
|
||||
{ "tick": 3, "low": 0.3, "mid": 0.7, "high": 0.5, "energy": 0.6 },
|
||||
{ "tick": 4, "low": 0.0, "mid": 0.1, "high": 0.2, "energy": 0.1 }
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ExhibitValidator } from '../tools/validate-exhibit.mjs';
|
||||
import {
|
||||
attackValue,
|
||||
bindingValue,
|
||||
releaseValue,
|
||||
resolveNumericTarget,
|
||||
selectWinningOverride,
|
||||
smoothBinding
|
||||
} from '../tools/gc3-resolution-model.mjs';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(new URL('./fixtures/gc3/resolution-traces.json', import.meta.url)));
|
||||
const closeTo = (actual, expected, epsilon = 1e-12) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
||||
|
||||
test('direct bus override masks a live binding and releases toward its current value', () => {
|
||||
for (const row of fixture.traces.directBusOverride) {
|
||||
const overrides = row.override === undefined
|
||||
? []
|
||||
: [{ id: 'direct', priority: 0, activationSequence: 1, value: row.override }];
|
||||
let resolved = resolveNumericTarget({ base: 0, binding: row.binding, overrides, safetyClamp: [0, 1] }).resolved;
|
||||
if (row.releaseProgress !== undefined) resolved = releaseValue(0.8, row.binding, row.releaseProgress);
|
||||
closeTo(resolved, row.resolved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a parameter override feeds a downstream binding in the same tick and preserves user edits', () => {
|
||||
for (const row of fixture.traces.parameterOverrideFeedsBinding) {
|
||||
let activity = row.activityOverride ?? row.storedActivity;
|
||||
if (row.releaseProgress !== undefined) activity = releaseValue(0.9, row.storedActivity, row.releaseProgress);
|
||||
const gain = bindingValue(activity, { scale: 0.5, offset: 0.5, clamp: [0, 1] });
|
||||
closeTo(activity, row.resolvedActivity);
|
||||
closeTo(gain, row.busGain);
|
||||
}
|
||||
});
|
||||
|
||||
test('priority and activation sequence decide competition independently of scope', () => {
|
||||
const overrides = [
|
||||
{ id: 'scenario', scope: 'scenario', priority: 4, activationSequence: 2, value: 0.4 },
|
||||
{ id: 'duration-low', scope: 'duration', priority: 3, activationSequence: 99, value: 0.9 }
|
||||
];
|
||||
assert.equal(selectWinningOverride(overrides).id, 'scenario');
|
||||
|
||||
overrides.push({ id: 'duration-tie', scope: 'duration', priority: 4, activationSequence: 3, value: 0.7 });
|
||||
assert.equal(selectWinningOverride(overrides).id, 'duration-tie');
|
||||
});
|
||||
|
||||
test('automation continues while masked and becomes the live release destination', () => {
|
||||
for (const row of fixture.traces.automationMaskedThenReleased) {
|
||||
const overrides = row.override === undefined
|
||||
? []
|
||||
: [{ id: 'mask', priority: 1, activationSequence: 1, value: row.override }];
|
||||
let resolved = resolveNumericTarget({ base: 0, automation: row.automation, overrides }).resolved;
|
||||
if (row.releaseProgress !== undefined) resolved = releaseValue(0.75, row.automation, row.releaseProgress);
|
||||
closeTo(resolved, row.resolved);
|
||||
}
|
||||
});
|
||||
|
||||
test('legal modulation follows override and safety clamp runs last', () => {
|
||||
const result = resolveNumericTarget({
|
||||
base: 0.2,
|
||||
overrides: [{ id: 'boost', priority: 0, activationSequence: 1, value: 0.9 }],
|
||||
modulation: 0.3,
|
||||
safetyClamp: [0, 1]
|
||||
});
|
||||
assert.equal(result.afterOverride, 0.9);
|
||||
closeTo(result.beforeClamp, 1.2);
|
||||
assert.equal(result.resolved, 1);
|
||||
});
|
||||
|
||||
test('binding smoothing is exact, and re-enable initializes without replaying disabled time', () => {
|
||||
const first = smoothBinding(undefined, 0.8, fixture.tickSeconds, 0.05);
|
||||
assert.equal(first, 0.8);
|
||||
const second = smoothBinding(first, 0.2, fixture.tickSeconds, 0.05);
|
||||
closeTo(second, 0.8 + (1 - Math.exp(-fixture.tickSeconds / 0.05)) * (0.2 - 0.8));
|
||||
assert.equal(smoothBinding(undefined, 0.35, fixture.tickSeconds, 0.05), 0.35);
|
||||
});
|
||||
|
||||
test('interrupted transitions begin from the visible value without cancelling lower overrides', () => {
|
||||
closeTo(attackValue(0.2, 0.8, 0.5, 'linear'), 0.5);
|
||||
const lower = { id: 'lower', priority: 1, activationSequence: 1, value: 0.5 };
|
||||
const higher = { id: 'higher', priority: 2, activationSequence: 2, value: attackValue(0.5, 1, 0, 'linear') };
|
||||
assert.equal(selectWinningOverride([lower, higher]).value, 0.5);
|
||||
higher.live = false;
|
||||
assert.equal(selectWinningOverride([lower, higher]).id, 'lower');
|
||||
});
|
||||
|
||||
test('validator rejects conflicting writers, cycles, unsupported targets, and draft aliases', () => {
|
||||
const base = {
|
||||
xzbt: '0.1',
|
||||
meta: { id: 'gc3-validation', name: 'GC3 validation' },
|
||||
parameters: { activity: { type: 'number', default: 0.5 } },
|
||||
state: { energy: { type: 'number', initial: 0.2 } }
|
||||
};
|
||||
|
||||
const cases = [
|
||||
{
|
||||
code: 'ERR_CONFLICTING_BINDING',
|
||||
bindings: [
|
||||
{ source: 'parameters.activity', target: 'state.energy' },
|
||||
{ source: 'parameters.activity', target: 'state.energy', when: { op: 'gt', left: 1, right: 0 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'ERR_CYCLIC_DEPENDENCY',
|
||||
bindings: [
|
||||
{ source: 'parameters.activity', target: 'state.energy' },
|
||||
{ source: 'state.energy', target: 'parameters.activity' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'ERR_UNSUPPORTED_TARGET',
|
||||
bindings: [{ source: 'parameters.activity', target: 'signals.time.elapsed' }]
|
||||
},
|
||||
{
|
||||
code: 'ERR_UNKNOWN_FIELD',
|
||||
bindings: [{ from: 'parameters.activity', to: 'state.energy' }]
|
||||
}
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const result = new ExhibitValidator({ ...base, bindings: entry.bindings }).validate();
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.errors.some((error) => error.code === entry.code), JSON.stringify(result.errors));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createRandomStream,
|
||||
deriveStreamState,
|
||||
FixedStepClock,
|
||||
GC4_CONSTANTS,
|
||||
planAudioUnlock
|
||||
} from '../tools/gc4-time-model.mjs';
|
||||
|
||||
function simulateFrames(frameDurationMs, durationMs) {
|
||||
const clock = new FixedStepClock();
|
||||
const decisions = [];
|
||||
clock.advance(0);
|
||||
for (let now = frameDurationMs; now <= durationMs + 1e-7; now += frameDurationMs) {
|
||||
for (const tick of clock.advance(Math.min(now, durationMs)).ticks) {
|
||||
if (tick % 10 === 0) decisions.push(tick);
|
||||
}
|
||||
}
|
||||
return { tickIndex: clock.tickIndex, decisions };
|
||||
}
|
||||
|
||||
test('render frequency does not change fixed-tick decisions', () => {
|
||||
const at60 = simulateFrames(1000 / 60, 1000);
|
||||
const at120 = simulateFrames(1000 / 120, 1000);
|
||||
assert.deepEqual(at60, at120);
|
||||
assert.equal(at60.tickIndex, 60);
|
||||
assert.deepEqual(at60.decisions, [10, 20, 30, 40, 50, 60]);
|
||||
});
|
||||
|
||||
test('long stalls have bounded work and do not replay discarded wall time', () => {
|
||||
const clock = new FixedStepClock();
|
||||
clock.advance(0);
|
||||
const stalled = clock.advance(1000);
|
||||
assert.equal(stalled.stalled, true);
|
||||
assert.equal(stalled.ticks.length, GC4_CONSTANTS.maxTicksPerTurn);
|
||||
assert.ok(clock.accumulatorMs < GC4_CONSTANTS.stepMs);
|
||||
});
|
||||
|
||||
test('visibility restoration does not clear explicit pause', () => {
|
||||
const clock = new FixedStepClock();
|
||||
clock.advance(0);
|
||||
clock.advance(GC4_CONSTANTS.stepMs);
|
||||
assert.equal(clock.tickIndex, 1);
|
||||
|
||||
clock.setPauseReason('explicit', true, 20);
|
||||
clock.setPauseReason('hidden', true, 20);
|
||||
clock.advance(10_000);
|
||||
clock.setPauseReason('hidden', false, 10_000);
|
||||
clock.advance(11_000);
|
||||
assert.equal(clock.tickIndex, 1);
|
||||
|
||||
clock.setPauseReason('explicit', false, 11_000);
|
||||
clock.advance(11_000 + GC4_CONSTANTS.stepMs);
|
||||
assert.equal(clock.tickIndex, 2);
|
||||
});
|
||||
|
||||
test('unlock skips pre-unlock one-shots and aligns a live continuous sound', () => {
|
||||
const plan = planAudioUnlock([
|
||||
{ id: 'expired', kind: 'one-shot', start: 1, end: 1.2 },
|
||||
{ id: 'partial', kind: 'one-shot', start: 4.9, end: 5.2 },
|
||||
{ id: 'ambience', kind: 'continuous', start: 2 },
|
||||
{ id: 'future', kind: 'one-shot', start: 5.05, end: 5.2 }
|
||||
], 5);
|
||||
assert.deepEqual(plan, [
|
||||
{ id: 'expired', action: 'skip' },
|
||||
{ id: 'partial', action: 'skip' },
|
||||
{ id: 'ambience', action: 'start-continuous', phase: 3, rampMs: 20 },
|
||||
{ id: 'future', action: 'schedule', delay: 0.04999999999999982 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('PRNG state and output are stable known vectors', () => {
|
||||
assert.deepEqual(deriveStreamState(42, 'scenario', 'storm:1'), [
|
||||
471422921, 3327820418, 120898024, 15893603
|
||||
]);
|
||||
const stream = createRandomStream(42, 'scenario', 'storm:1');
|
||||
assert.deepEqual([
|
||||
stream.nextUint32(), stream.nextUint32(), stream.nextUint32(), stream.nextUint32()
|
||||
], [4101533927, 4149236977, 2113172072, 1672809915]);
|
||||
});
|
||||
|
||||
test('manual sampling does not perturb cadence or scenario streams', () => {
|
||||
const cadenceA = createRandomStream(7, 'cadence', 'routine:1');
|
||||
const cadenceB = createRandomStream(7, 'cadence', 'routine:1');
|
||||
const scenarioA = createRandomStream(7, 'scenario', 'surge:1');
|
||||
const scenarioB = createRandomStream(7, 'scenario', 'surge:1');
|
||||
const manual = createRandomStream(7, 'manual-sample', 'warning:1');
|
||||
|
||||
const before = [cadenceA.nextUint32(), scenarioA.nextUint32()];
|
||||
for (let index = 0; index < 100; index++) manual.nextUint32();
|
||||
const after = [cadenceA.nextUint32(), scenarioA.nextUint32()];
|
||||
|
||||
assert.deepEqual(before, [cadenceB.nextUint32(), scenarioB.nextUint32()]);
|
||||
assert.deepEqual(after, [cadenceB.nextUint32(), scenarioB.nextUint32()]);
|
||||
});
|
||||
|
||||
test('audio-reactive decisions use the same deterministic signal fixture at every render rate', () => {
|
||||
const signals = JSON.parse(readFileSync(new URL('./fixtures/gc4/deterministic-signals.json', import.meta.url)));
|
||||
const decide = () => signals.filter((signal) => signal.energy >= 0.5).map((signal) => signal.tick);
|
||||
assert.deepEqual(decide(), [2, 3]);
|
||||
assert.deepEqual(decide(), decide());
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
CLEANUP_TICKS,
|
||||
ConditionTrigger,
|
||||
DeferredStartQueue,
|
||||
DISPATCH_BUDGET,
|
||||
drainDispatch,
|
||||
OwnershipModel
|
||||
} from '../tools/gc5-lifecycle-model.mjs';
|
||||
|
||||
test('resources created through nested events inherit the scenario owner', () => {
|
||||
const runtime = new OwnershipModel();
|
||||
runtime.startScenario('scenario-1');
|
||||
runtime.executeActions([{ type: 'event', actions: [{ type: 'event', actions: [{ type: 'resource', kind: 'sound' }] }] }], 'scenario-1');
|
||||
assert.equal(runtime.ownedResourceCount('scenario-1'), 1);
|
||||
});
|
||||
|
||||
test('owner termination bounds duration override release and eventually disposes it', () => {
|
||||
const runtime = new OwnershipModel();
|
||||
runtime.startScenario('scenario-1');
|
||||
runtime.executeActions([{ type: 'resource', kind: 'override', releaseTicks: CLEANUP_TICKS * 10 }], 'scenario-1');
|
||||
runtime.terminate('scenario-1', 'cancel');
|
||||
assert.equal(runtime.ownedResourceCount('scenario-1'), 0);
|
||||
assert.equal(runtime.resources.size, 1);
|
||||
runtime.tick(CLEANUP_TICKS);
|
||||
assert.equal(runtime.resources.size, 0);
|
||||
assert.ok(runtime.diagnostics.some((entry) => entry.code === 'WARN_CLEANUP_FORCED'));
|
||||
});
|
||||
|
||||
test('persistent set survives a later critical ordinary-action failure', () => {
|
||||
const runtime = new OwnershipModel();
|
||||
runtime.startScenario('scenario-1', { onCancel: [{ type: 'set', target: 'state.cleanup-ran', value: true }] });
|
||||
runtime.executeActions([
|
||||
{ type: 'set', target: 'state.mode', value: 'changed' },
|
||||
{ type: 'resource', kind: 'visual' },
|
||||
{ type: 'fail', critical: true }
|
||||
], 'scenario-1');
|
||||
assert.equal(runtime.state.get('state.mode'), 'changed');
|
||||
assert.equal(runtime.state.get('state.cleanup-ran'), true);
|
||||
assert.equal(runtime.owners.get('scenario-1').status, 'FAILED');
|
||||
assert.equal(runtime.resources.size, 0);
|
||||
});
|
||||
|
||||
test('startup and termination-hook failures still complete cleanup with the original terminal cause', () => {
|
||||
const runtime = new OwnershipModel();
|
||||
runtime.startScenario('scenario-1', {
|
||||
onStart: [{ type: 'resource', kind: 'audio' }, { type: 'fail', critical: true }],
|
||||
onCancel: [{ type: 'fail', critical: true }, { type: 'set', target: 'state.after-hook-failure', value: 1 }]
|
||||
});
|
||||
assert.equal(runtime.owners.get('scenario-1').status, 'FAILED');
|
||||
assert.equal(runtime.state.get('state.after-hook-failure'), 1);
|
||||
assert.equal(runtime.resources.size, 0);
|
||||
assert.equal(runtime.diagnostics.filter((entry) => entry.code === 'ERR_ACTION_FAILURE').length, 2);
|
||||
});
|
||||
|
||||
test('condition trigger requires a false tick before initial and subsequent firing', () => {
|
||||
const trigger = new ConditionTrigger(2);
|
||||
assert.equal(trigger.evaluate(true), false);
|
||||
assert.equal(trigger.evaluate(false), false);
|
||||
assert.equal(trigger.evaluate(true), false);
|
||||
assert.equal(trigger.evaluate(true), true);
|
||||
assert.equal(trigger.evaluate(true), false);
|
||||
assert.equal(trigger.evaluate(false), false);
|
||||
assert.equal(trigger.evaluate(true), false);
|
||||
assert.equal(trigger.evaluate(true), true);
|
||||
});
|
||||
|
||||
test('deferred queue keeps one request per definition, expires, rechecks, and orders deterministically', () => {
|
||||
const queue = new DeferredStartQueue(10);
|
||||
assert.equal(queue.defer({ definitionId: 'low', priority: 10, creationTick: 1, documentOrder: 0, sampledInputs: 0.2 }), true);
|
||||
assert.equal(queue.defer({ definitionId: 'low', priority: 99, creationTick: 2, documentOrder: 0, sampledInputs: 0.9 }), false);
|
||||
queue.defer({ definitionId: 'high-late', priority: 80, creationTick: 2, documentOrder: 2 });
|
||||
queue.defer({ definitionId: 'high-early', priority: 80, creationTick: 1, documentOrder: 1 });
|
||||
|
||||
const started = queue.dispatch(3, (request) => request.definitionId !== 'low');
|
||||
assert.deepEqual(started.map((request) => request.definitionId), ['high-early', 'high-late']);
|
||||
assert.equal(queue.requests.get('low').sampledInputs, 0.2);
|
||||
assert.deepEqual(queue.dispatch(11, () => true), []);
|
||||
assert.equal(queue.requests.size, 0);
|
||||
});
|
||||
|
||||
test('feedback is bounded and leaves cleanup capacity outside the ordinary budget', () => {
|
||||
const result = drainDispatch([{ path: 'event.loop', ownerId: 'scenario-1' }], (unit) => [unit]);
|
||||
assert.equal(result.exhausted, true);
|
||||
assert.equal(result.consumed, DISPATCH_BUDGET);
|
||||
assert.equal(result.discarded.length, 1);
|
||||
assert.deepEqual(result.failedOwnerIds, ['scenario-1']);
|
||||
|
||||
const runtime = new OwnershipModel();
|
||||
runtime.startScenario('scenario-1');
|
||||
runtime.executeActions([{ type: 'resource' }], 'scenario-1');
|
||||
runtime.terminate('scenario-1', 'failure');
|
||||
assert.equal(runtime.resources.size, 0);
|
||||
});
|
||||
|
||||
test('repeated completion, cancellation, and failure cycles return counters to baseline', () => {
|
||||
const runtime = new OwnershipModel();
|
||||
for (let index = 0; index < 30; index++) {
|
||||
const id = `scenario-${index}`;
|
||||
runtime.startScenario(id);
|
||||
runtime.executeActions([
|
||||
{ type: 'resource', kind: 'sound', releaseTicks: 2 },
|
||||
{ type: 'event', actions: [{ type: 'resource', kind: 'visual', releaseTicks: 3 }] }
|
||||
], id);
|
||||
runtime.terminate(id, index % 3 === 0 ? 'complete' : index % 3 === 1 ? 'cancel' : 'failure');
|
||||
runtime.tick(3);
|
||||
}
|
||||
assert.equal(runtime.resources.size, 0);
|
||||
assert.equal(runtime.owners.size, 30);
|
||||
assert.ok([...runtime.owners.values()].every((owner) => ['COMPLETED', 'CANCELLED', 'FAILED'].includes(owner.status)));
|
||||
});
|
||||
Reference in New Issue
Block a user