Show completed core actions and summarize notice bursts
Tag committed ordinary movement, shipping, revival, collection, battle, native Guild transport and HMS relocation for the existing house notices and optional sounds. Keep pending and canceled actions free of false completion notices and preserve richer Smuggler and card-specific labels. Bound feedback to three 1.5-second notices including the active notice. Summarize older pending events with an exact count and retain individual chronicle entries. Consume stale, duplicate, muted and hidden updates without replay; clear visible and queued feedback when the page hides. These changes add log metadata and cosmetic presentation only. Validation: types, lint, 5137 offline tests and production build passed. Independent review resolved one duplicate Smuggler notice. Nine engine regressions and six queue/cursor/component tests cover the new behavior. Two Basic/Advanced four-player games completed with 821 legal actions, 21 JSON restores and zero rejected actions; no AI tuning was done. Browser QA 76CMLL8Z used genuine Basic setup and private real-action batches. A seven-event burst displayed Revival plus four earlier actions, then drained. Chronicle navigation and silent toggle/refresh were checked; refresh kept the saved row identical. Phone notice bounds fit without overflow. Transient phone text and actual background visibility were not captured; screenshot and full visual acceptance remain open. All 1,585 prior rooms, 3,353 seats and auxiliary records stayed unchanged. One QA room and human seat were added; no reset, restart or automation.
This commit is contained in:
1 parent
7136c8dd19
commit
a3bc0ac837
12 files changed
+495
-48
No files matched your search
@@ -108,6 +108,10 @@ sample. Short cues mark phase changes and automatic action notices. Sound starts
|
||||
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
|
||||
for activation, background behavior and prototype limits.
|
||||
|
||||
**Automatic action notices** briefly identify completed actions in house colors.
|
||||
Bursts summarize older notices while retaining their details in the chronicle;
|
||||
see [completed-action feedback](docs/ACTION_FEEDBACK.md).
|
||||
|
||||
## Verify changes
|
||||
|
||||
Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests
|
||||
|
||||
@@ -8,19 +8,14 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { Bell, BellOff } from 'lucide-react';
|
||||
import { FACTIONS, type FactionId } from '@/game/catalog';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import { advanceActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type AutomaticActionEvent = {
|
||||
id: string;
|
||||
seq: number;
|
||||
faction: FactionId;
|
||||
name: string;
|
||||
};
|
||||
export type { AutomaticActionEvent } from '@/lib/action-notice-queue';
|
||||
|
||||
const PREFERENCE_KEY = 'dune.automatic-action-notices';
|
||||
const PREFERENCE_EVENT = 'dune-automatic-action-notices-preference';
|
||||
const MAX_QUEUED_NOTICES = 250;
|
||||
let memoryPreference: boolean | undefined;
|
||||
function readPreference() {
|
||||
if (memoryPreference !== undefined) return memoryPreference;
|
||||
@@ -51,10 +46,10 @@ export function AutomaticActionNotice({
|
||||
const seenSeq = useRef(
|
||||
events.reduce((last, event) => Math.max(last, event.seq), -1),
|
||||
);
|
||||
const queue = useRef<AutomaticActionEvent[]>([]);
|
||||
const active = useRef<AutomaticActionEvent | null>(null);
|
||||
const queue = useRef<QueuedActionNotice[]>([]);
|
||||
const active = useRef<QueuedActionNotice | null>(null);
|
||||
const [notice, setNotice] = useState<
|
||||
(AutomaticActionEvent & { fading: boolean }) | null
|
||||
(QueuedActionNotice & { fading: boolean }) | null
|
||||
>(null);
|
||||
const [completed, setCompleted] = useState(0);
|
||||
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
@@ -65,7 +60,7 @@ export function AutomaticActionNotice({
|
||||
|
||||
const showNext = useCallback(() => {
|
||||
if (active.current) return;
|
||||
if (!readPreference()) {
|
||||
if (!readPreference() || document.visibilityState === 'hidden') {
|
||||
queue.current = [];
|
||||
return;
|
||||
}
|
||||
@@ -91,23 +86,29 @@ export function AutomaticActionNotice({
|
||||
}, [clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
const fresh = events
|
||||
.filter((event) => event.seq > seenSeq.current)
|
||||
.toSorted((a, b) => a.seq - b.seq);
|
||||
const additions: AutomaticActionEvent[] = [];
|
||||
for (const event of fresh) {
|
||||
if (event.seq <= seenSeq.current) continue;
|
||||
seenSeq.current = event.seq;
|
||||
additions.push(event);
|
||||
}
|
||||
// Disabled notices are consumed, so enabling cannot replay missed events.
|
||||
if (!readPreference()) return;
|
||||
const capacity = MAX_QUEUED_NOTICES - (active.current ? 1 : 0);
|
||||
// Preserve the active notice and retain the newest pending events on overflow.
|
||||
queue.current = [...queue.current, ...additions].slice(-capacity);
|
||||
// Preserve the visible notice. Summarize older pending actions so a bot
|
||||
// batch cannot leave minutes of stale feedback; the chronicle keeps detail.
|
||||
const next = advanceActionNotices({ seenSeq: seenSeq.current, pending: queue.current }, events, {
|
||||
active: !!active.current,
|
||||
show: readPreference() && document.visibilityState !== 'hidden',
|
||||
});
|
||||
seenSeq.current = next.seenSeq;
|
||||
queue.current = next.pending;
|
||||
showNext();
|
||||
}, [events, showNext, completed]);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
};
|
||||
document.addEventListener('visibilitychange', hide);
|
||||
return () => document.removeEventListener('visibilitychange', hide);
|
||||
}, [clearTimers]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimers();
|
||||
@@ -168,6 +169,9 @@ export function AutomaticActionNotice({
|
||||
<span className="mt-1 block break-words text-sm font-medium leading-5">
|
||||
{notice.name}
|
||||
</span>
|
||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||
</span>}
|
||||
</span>
|
||||
)}
|
||||
</output>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Completed-action feedback
|
||||
|
||||
The existing optional house-colored notice now covers ordinary completed
|
||||
shipments, ground movement, force and leader revival, spice collection, battle
|
||||
results, native Guild transport and Hidden Mobile Stronghold relocation. Existing
|
||||
card-specific and faction-specific labels remain where they give more context.
|
||||
These notices describe committed results. Declaring a shipment or waiting for a
|
||||
response does not announce that the shipment has already happened.
|
||||
|
||||
The server adds only a public faction and short action name to the existing
|
||||
chronicle entry. No private card identity, plan, force denomination or balance
|
||||
is added to the notice. Rules, choices, payments, force custody and server AI
|
||||
pacing are unchanged. The optional sound system already consumes these tags;
|
||||
its separate mute and volume settings still apply.
|
||||
|
||||
## Bursts, refresh and controls
|
||||
|
||||
Each notice lasts 1.5 seconds and does not intercept pointer or keyboard input.
|
||||
The queue holds at most three notices including the visible one. When more
|
||||
actions arrive, older pending notices merge into a count beside the most recent
|
||||
action in that group; their individual details remain in the table chronicle.
|
||||
A new burst never replaces the currently displayed notice. After arrivals stop,
|
||||
the visible notice and pending queue finish within 4.5 seconds, rather than
|
||||
retaining minutes of old events.
|
||||
|
||||
Initial and restored snapshots establish a silent baseline. Already-seen events
|
||||
do not replay. Events received while notices are off or the page is hidden are
|
||||
consumed silently. Hiding the page clears any visible or queued notice. The
|
||||
**Automatic action notices** toggle remains cosmetic; it cannot submit a game
|
||||
action or change a saved decision. Reduced motion removes the fade.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
`tests/action-notice-queue.test.tsx` checks small updates, a full 250-entry burst,
|
||||
successive bursts during an active notice, exact represented counts and the
|
||||
initial nonblocking output, plus stale/duplicate sequence handling and silent
|
||||
consumption without replay. `tests/completed-action-events.test.ts` exercises
|
||||
committed results and pending/canceled paths. Private checkpoint evidence records
|
||||
the browser checks and final source-bound verification.
|
||||
|
||||
Browser QA used genuine Basic Atreides/Harkonnen/Emperor setup. Private batches
|
||||
advanced only that QA room through real engine actions, preserving card/force
|
||||
custody and seat controllers. A seven-event burst displayed the Emperor's
|
||||
**Revival** with four earlier completed actions, then drained. The chronicle
|
||||
remained usable and the notice had no pointer interception. A disabled notice
|
||||
control stayed empty as new chronicle entries arrived; re-enabling and refreshing
|
||||
left it empty. Refresh retained the exact saved game row. At a 390-pixel viewport,
|
||||
the notice container was 343 pixels within a 375-pixel document without horizontal
|
||||
overflow; transient mobile text was not captured. The viewport was reset afterward.
|
||||
|
||||
The provider reports background tabs as visible, so an actual background-page
|
||||
transition was not exercised. The cursor tests and handler review cover that
|
||||
branch; the automated component test does not mount timers. Existing screenshot
|
||||
capture failures leave full visual acceptance open.
|
||||
|
||||
This extends functional feedback; complete animation design, every successful
|
||||
power tag and full visual/device acceptance remain unfinished. It does not
|
||||
remove any additional player decision or resolve pending rules interpretations.
|
||||
+16
-13
@@ -6,20 +6,23 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest delivered checkpoint: `d1bf33d`, [Basic CHOAM with Leader Skills](CHOAM_LEADER_SKILLS.md).
|
||||
Types, lint, **5,101 offline tests** and the production build passed; five
|
||||
complete two-through-six-player samples had no rejected actions across 3,067
|
||||
actions and 79 JSON restores. Browser setup, market Ghola revival, private
|
||||
skill refresh, mobile assignment and exact sale settlement were checked.
|
||||
All 1,583 prior rooms and 3,351 seats remained intact; one QA room/seat brought
|
||||
the baseline to 1,584 rooms and 3,352 human seats.
|
||||
Screenshot capture timed out; full visual acceptance remains open.
|
||||
Latest delivered checkpoint: `7136c8d`, [Truthtrance physical-force questions](TRUTHTRANCE_FORCE_FACTS.md).
|
||||
Types, lint, **5,122 offline tests** and the production build passed; six
|
||||
complete Basic/Advanced samples had no rejected actions across 1,551 actions
|
||||
and 39 JSON restores. Browser compound questions, AI responses, mobile controls
|
||||
and exact human private-answer refresh were checked. All 1,584 prior rooms and
|
||||
3,352 seats remained intact; one QA room/seat brought the baseline to 1,585 rooms
|
||||
and 3,353 human seats. Full visual acceptance remains open.
|
||||
|
||||
Current work adds [Truthtrance physical-force questions](TRUTHTRANCE_FORCE_FACTS.md):
|
||||
normal, elite or total counters in reserves, the Tanks or one board location.
|
||||
Existing compound questions, private answers, card retirement and saved history
|
||||
are reused. These are present facts, without hidden No-Field values or future
|
||||
movement constraints. Saved facts are revalidated before actions or projection.
|
||||
Current work connects [completed-action feedback](ACTION_FEEDBACK.md) for core
|
||||
movement, shipment, revival, collection and battle results. Existing cosmetic
|
||||
notices coalesce bursts into at most three visible/pending notices, with complete
|
||||
chronicle details retained; hidden pages clear and silently consume feedback.
|
||||
|
||||
A bounded follow-up candidate is the forced Harkonnen special-Karama return
|
||||
when every held card must go back. Automatic settlement must preserve a private
|
||||
saved record of the randomly inspected cards and the original interruption.
|
||||
This is unfinished; larger hands must keep their real return choice.
|
||||
|
||||
The [Sandmaster/HMS source comparison](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility)
|
||||
found no official clarification that passengers remaining in the interior
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 21 September 2026 — Completed-action feedback
|
||||
|
||||
Core committed movement, shipping, revival, collection and battle results now
|
||||
feed the existing house-colored notices and optional sound. Burst summaries
|
||||
retain every represented action count while limiting the visible/pending queue
|
||||
to three notices. Hidden pages consume updates silently and clear old feedback.
|
||||
See [coverage and verification](ACTION_FEEDBACK.md). Rules and AI strategy are
|
||||
unchanged; full visual acceptance and remaining power tags stay open.
|
||||
|
||||
## 21 September 2026 — Truthtrance physical-force questions
|
||||
|
||||
Current force facts compare normal, elite or total physical counters in reserves,
|
||||
|
||||
@@ -43,3 +43,7 @@ to change sound settings. Settings were returned to off and 35% afterward.
|
||||
This is a functional prototype, not the final sound design. Successful powers
|
||||
without an automatic notice tag remain outside these cues. Per-component audio,
|
||||
broader device/browser acceptance and final sound mixing remain unfinished.
|
||||
|
||||
[Completed-action feedback](ACTION_FEEDBACK.md) extends the tagged events to
|
||||
ordinary movement, shipping, revival, collection and battle results. The sound
|
||||
system keeps its existing silent baseline, coalescing and independent controls.
|
||||
+10
-5
@@ -9889,6 +9889,7 @@ function finishMovedGroup(g: Game, move: CompletedMovement) {
|
||||
move.noField
|
||||
? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${territory(origin).name} to ${territory(to).name}. Its value remains concealed.`
|
||||
: `${p.name} moved ${n} forces from ${movementOrigins(move).map(source => territory(source).name).join(' and ')} to ${territory(to).name}.`,
|
||||
{ faction: p.faction, name: 'Movement' },
|
||||
);
|
||||
intrusion(g, p, to);
|
||||
if (movementOrigins(move).some(source => source !== to)) openTerritoryEntry(g, p, to, s, n, elite, 'movement');
|
||||
@@ -13445,6 +13446,7 @@ function commitCollection(
|
||||
log(
|
||||
g,
|
||||
`${faction(p.faction).name} collected ${receipt.collected} spice.`,
|
||||
{ faction: p.faction, name: 'Spice collection' },
|
||||
);
|
||||
}
|
||||
for (const effect of discovery.effects) {
|
||||
@@ -15851,7 +15853,7 @@ function resolveBattle(g: Game) {
|
||||
: `${faction(winner!.faction).name} won in ${combatLocationName(g, b.territory)} (${av}–${dv}${av === dv ? (strongholdEffect(g, winner!.id) === 'habbanya_ridge_sietch' ? ', Habbanya Stronghold advantage wins ties' : ', aggressor wins ties') : ''}).`,
|
||||
stoneResult
|
||||
? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' }
|
||||
: undefined,
|
||||
: { faction: winner!.faction, name: 'Battle' },
|
||||
);
|
||||
}
|
||||
if (jacurutuIncome?.kind === 'income' && jacurutuIncome.amount > 0) {
|
||||
@@ -17215,14 +17217,15 @@ function finishRevival(
|
||||
`${rule.name} accrued ${token.spice} spice for ${getPlayer(g, income.owner).name}, payable at phase end.`,
|
||||
);
|
||||
}
|
||||
log(g, `${p.name} revived ${n} forces.${g.homeworlds ? ` ${revival.free} were free; ${n - revival.free} were paid. ${payer.name} paid ${revival.cost} spice. Future revival requests use the resulting Homeworld population.` : ''}`);
|
||||
log(g, `${p.name} revived ${n} forces.${g.homeworlds ? ` ${revival.free} were free; ${n - revival.free} were paid. ${payer.name} paid ${revival.cost} spice. Future revival requests use the resulting Homeworld population.` : ''}`,
|
||||
{ faction: p.faction, name: 'Revival' });
|
||||
stageHomeworldRevivalReturn(g, p, source, group, deploymentGrant);
|
||||
} else {
|
||||
if (revival.kind === 'kwisatz') {
|
||||
p.kwisatz!.dead = false;
|
||||
p.kwisatz!.revivalCycle =
|
||||
Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1;
|
||||
log(g, `${p.name} revived Kwisatz Haderach.`);
|
||||
log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' });
|
||||
} else {
|
||||
const leader = g.players
|
||||
.flatMap((owner) => owner.leaders)
|
||||
@@ -17242,6 +17245,7 @@ function finishRevival(
|
||||
log(
|
||||
g,
|
||||
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.`,
|
||||
{ faction: p.faction, name: 'Revival' },
|
||||
);
|
||||
}
|
||||
p.leaderRevived = true;
|
||||
@@ -19092,7 +19096,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
|
||||
shipment.noField || shipment.alliedNoField
|
||||
? { faction: 'richese', name: 'No-Field shipment' }
|
||||
: shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
|
||||
: shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : shipment.smuggler ? undefined : { faction: p.faction, name: 'Shipment' },
|
||||
);
|
||||
if (shipment.smuggler) log(g,
|
||||
`${p.name}'s Smuggler included one free accompanying force: ${n} physical forces left reserves, priced as ${n - 1} for ${cost} spice. The destination was empty before arrival.`,
|
||||
@@ -19181,6 +19185,7 @@ function relocateMobileStronghold(
|
||||
log(
|
||||
g,
|
||||
`${p.name} moved the Hidden Mobile Stronghold to ${territory(loc.territory).name}, sector ${loc.sector}, collecting ${collected} spice.`,
|
||||
{ faction: p.faction, name: 'Hidden Mobile Stronghold' },
|
||||
);
|
||||
}
|
||||
function choamWorthlessBlocked(g: Game, card: string) {
|
||||
@@ -23829,7 +23834,7 @@ function applyActionInner(
|
||||
p.shipped = true;
|
||||
g.karamaShipping = null;
|
||||
log(g, `${p.name} used Guild transport for ${n} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
|
||||
guildSecretEvent ? {faction:'guild',name:'Secret Ally transport'} : undefined);
|
||||
guildSecretEvent ? {faction:'guild',name:'Secret Ally transport'} : { faction: p.faction, name: 'Guild transport' });
|
||||
if (to !== 'reserves') {
|
||||
intrusion(g, p, to);
|
||||
if (origin !== to)
|
||||
|
||||
+5
-3
@@ -1362,8 +1362,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'After a battle resolves, the server calculates casualty allocations consistent with the revealed wheel and spice support. If exactly one allocation is legal, it applies that allocation without asking for an identical confirmation.',
|
||||
'If several physical casualty combinations are legal, the winner still chooses. Automatic casualties preserve later optional Ixian substitutions, winner card cleanup and Moritani ally retention; they do not choose those benefits for you.',
|
||||
'Newly received tagged events queue in order, with the acting house and event name shown for approximately 1.5 seconds each. Use Automatic action notices to turn the display on or off. The preference belongs to this browser; hiding notices does not disable automatic rule settlement or change AI timing.',
|
||||
'The notice is cosmetic and does not stop game progression. Reduced-motion settings suppress its fade. Reloading treats existing events as a baseline rather than replaying them. Events received while notices are off are consumed; turning notices on does not replay them. The bounded queue keeps the newest pending events if an unusually large backlog exceeds its capacity.',
|
||||
'Current notice events cover automatic battle casualties, successful Ecaz Ambassador placement, Emperor auction income, Harkonnen bonus-card draws and private full-plan inspection availability and supported successful Ambassador effects. The Harkonnen event reports the draw without exposing its private card identity. The inspection notice reveals no plan details; the entitled player reads those in the persistent private panel. Uncancelable responses now settle automatically as described in Automatic allowance of powers. Successful powers without a notice tag remain outside the current notice coverage.',
|
||||
'The notice is cosmetic and does not stop game progression. Reduced-motion settings suppress its fade. Reloading treats existing events as a baseline rather than replaying them. Events received while notices are off are consumed; turning notices on does not replay them. At most three notices, including the visible one, remain queued. Bursts summarize older pending actions with a count while their details remain in the table chronicle. Hiding the page clears old notices and consumes updates silently.',
|
||||
'Current notice events include committed ordinary movement, shipment, revival, collection and battle results, native Guild transport and Hidden Mobile Stronghold relocation. Declared or canceled actions do not announce an uncompleted result. Existing notices also cover automatic casualties, successful Ecaz Ambassador placement, supported successful Ambassador effects, Emperor auction income, Harkonnen bonus-card draws and private full-plan inspection availability. The Harkonnen draw is announced without exposing its private card identity. Notices reveal no private card identities or plan details. Uncancelable responses now settle automatically as described in Automatic allowance of powers. Successful powers without a notice tag remain outside the current coverage.',
|
||||
],
|
||||
checklist: [
|
||||
{
|
||||
@@ -1376,7 +1376,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Player controls',
|
||||
status: 'Implemented',
|
||||
detail:
|
||||
'Multiple allocations and optional continuations retain controls. The cosmetic 1.5-second house notice has an on/off button and reduced-motion behavior.',
|
||||
'Multiple allocations and optional continuations retain controls. The cosmetic 1.5-second house notice has an on/off button, reduced-motion behavior and bounded burst summaries.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
@@ -1399,6 +1399,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/automatic-casualties.test.ts',
|
||||
'tests/moritani-retention.test.ts',
|
||||
'tests/duke-vidal-engine.test.ts',
|
||||
'tests/completed-action-events.test.ts',
|
||||
'tests/action-notice-queue.test.tsx',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
|
||||
export type AutomaticActionEvent = {
|
||||
id: string;
|
||||
seq: number;
|
||||
faction: FactionId;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type QueuedActionNotice = AutomaticActionEvent & { count: number };
|
||||
|
||||
export type ActionNoticeCursor = { seenSeq: number; pending: QueuedActionNotice[] };
|
||||
|
||||
/** Consume every observed sequence, even when presentation is disabled. */
|
||||
export function advanceActionNotices(
|
||||
cursor: ActionNoticeCursor,
|
||||
events: readonly AutomaticActionEvent[],
|
||||
options: { active: boolean; show: boolean },
|
||||
): ActionNoticeCursor {
|
||||
let seenSeq = cursor.seenSeq;
|
||||
const fresh: AutomaticActionEvent[] = [];
|
||||
for (const event of events.filter(event => event.seq > seenSeq).toSorted((a, b) => a.seq - b.seq)) {
|
||||
if (event.seq <= seenSeq) continue;
|
||||
seenSeq = event.seq;
|
||||
fresh.push(event);
|
||||
}
|
||||
return {
|
||||
seenSeq,
|
||||
pending: options.show ? appendActionNotices(cursor.pending, fresh, options.active) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep at most three 1.5-second notices, including the one already on screen. */
|
||||
export function appendActionNotices(
|
||||
pending: readonly QueuedActionNotice[],
|
||||
incoming: readonly AutomaticActionEvent[],
|
||||
active: boolean,
|
||||
): QueuedActionNotice[] {
|
||||
const notices = [...pending, ...incoming.map(event => ({ ...event, count: 1 }))];
|
||||
const capacity = active ? 2 : 3;
|
||||
if (notices.length <= capacity) return notices;
|
||||
const mergedLength = notices.length - capacity + 1;
|
||||
const merged = notices.slice(0, mergedLength);
|
||||
return [
|
||||
{ ...merged[merged.length - 1], count: merged.reduce((sum, notice) => sum + notice.count, 0) },
|
||||
...notices.slice(mergedLength),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AutomaticActionNotice } from '../components/automatic-action-notice';
|
||||
import { advanceActionNotices, appendActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue';
|
||||
|
||||
const events = (first: number, count: number): AutomaticActionEvent[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
id: `room:${first + index}`, seq: first + index,
|
||||
faction: index % 2 ? 'emperor' : 'atreides', name: `Action ${first + index}`,
|
||||
}));
|
||||
|
||||
void test('ordinary notices keep their public action and house without mutating inputs', () => {
|
||||
const incoming = events(10, 3), before = structuredClone(incoming);
|
||||
assert.deepEqual(appendActionNotices([], incoming, false), incoming.map(event => ({ ...event, count: 1 })));
|
||||
assert.deepEqual(incoming, before);
|
||||
assert.deepEqual(appendActionNotices([], [], false), []);
|
||||
});
|
||||
|
||||
void test('a full log batch becomes three brief notices while accounting for every action', () => {
|
||||
const incoming = events(1, 250);
|
||||
const queue = appendActionNotices([], incoming, false);
|
||||
assert.equal(queue.length, 3);
|
||||
assert.deepEqual(queue.map(notice => notice.seq), [248, 249, 250]);
|
||||
assert.deepEqual(queue.map(notice => notice.count), [248, 1, 1]);
|
||||
assert.equal(queue.reduce((sum, notice) => sum + notice.count, 0), 250);
|
||||
assert.equal(queue[0].name, incoming[247].name);
|
||||
assert.equal(queue[0].faction, incoming[247].faction);
|
||||
});
|
||||
|
||||
void test('incoming bursts preserve the visible notice and merge existing summaries without losing counts', () => {
|
||||
const queue = appendActionNotices([], events(1, 12), false);
|
||||
const active = queue[0], before = structuredClone(queue);
|
||||
const next = appendActionNotices(queue.slice(1), events(13, 20), true);
|
||||
assert.equal(next.length, 2);
|
||||
assert.deepEqual(next.map(notice => notice.seq), [31, 32]);
|
||||
assert.equal(active.count + next.reduce((sum, notice) => sum + notice.count, 0), 32);
|
||||
const again = appendActionNotices(next, events(33, 2), true);
|
||||
assert.deepEqual(again.map(notice => notice.seq), [33, 34]);
|
||||
assert.equal(active.count + again.reduce((sum, notice) => sum + notice.count, 0), 34);
|
||||
assert.deepEqual(queue, before);
|
||||
});
|
||||
|
||||
void test('the initial render establishes a silent baseline and leaves a nonblocking accessible output', () => {
|
||||
const markup = renderToStaticMarkup(<AutomaticActionNotice events={events(1, 250)} />);
|
||||
assert.match(markup, /aria-label="Automatic action notices"/);
|
||||
assert.match(markup, /aria-pressed="true"/);
|
||||
assert.match(markup, /<output[^>]+aria-live="polite"/);
|
||||
assert.match(markup, /pointer-events-none/);
|
||||
assert.doesNotMatch(markup, /Action 250|earlier actions also completed|role="dialog"/);
|
||||
});
|
||||
|
||||
void test('duplicate and stale snapshots do not replay, while new sequences remain ordered', () => {
|
||||
const old = events(1, 5), fresh = events(6, 3);
|
||||
const next = advanceActionNotices({ seenSeq: 5, pending: [] }, [...fresh.toReversed(), ...old, fresh[1]], { active: false, show: true });
|
||||
assert.equal(next.seenSeq, 8);
|
||||
assert.deepEqual(next.pending.map(notice => notice.seq), [6, 7, 8]);
|
||||
assert.deepEqual(next.pending.map(notice => notice.count), [1, 1, 1]);
|
||||
assert.deepEqual(advanceActionNotices({ ...next, pending: [] }, old, { active: false, show: true }), { seenSeq: 8, pending: [] });
|
||||
});
|
||||
|
||||
void test('muted or hidden updates consume their sequences and clear pending feedback without later replay', () => {
|
||||
const initial = { seenSeq: 2, pending: appendActionNotices([], events(1, 2), false) };
|
||||
const quiet = advanceActionNotices(initial, events(1, 20), { active: true, show: false });
|
||||
assert.deepEqual(quiet, { seenSeq: 20, pending: [] });
|
||||
const shown = advanceActionNotices(quiet, events(1, 20), { active: false, show: true });
|
||||
assert.deepEqual(shown, quiet);
|
||||
const next = advanceActionNotices(shown, events(18, 4), { active: false, show: true });
|
||||
assert.deepEqual(next.pending, [{ ...events(21, 1)[0], faction: 'emperor', count: 1 }]);
|
||||
assert.equal(initial.pending.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer,
|
||||
normalizeAutomaticGame, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { mobileRoutes } from '../game/board';
|
||||
import { completedChoamSkillsGame, assertChoamSkillsCustody } from './choam-skills-fixture';
|
||||
import { completedIxSkillsGame, assertIxSkillsCustody } from './ix-skills-fixture';
|
||||
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
const player = (game: Game, id: string) => game.players.find(candidate => candidate.id === id)!;
|
||||
const act = (game: Game, owner: string, action: Action) => reload(applyAction(reload(game), owner, action));
|
||||
const lastSeq = (game: Game) => game.log.at(-1)?.seq ?? 0;
|
||||
const notices = (game: Game, name: string, since = 0) =>
|
||||
game.log.filter(entry => entry.seq > since && entry.automatic?.name === name);
|
||||
|
||||
/** Genuine setup; tests stage only later conserved positions and eligible Tanks. */
|
||||
function base(advanced = false) {
|
||||
let game = createGame('ACTIONNOTICE', newPlayer('a', 'Atreides', 'atreides'), advanced);
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'ready' });
|
||||
game = initializeBaseGameForAudit(game);
|
||||
for (const p of game.players)
|
||||
game = act(game, p.id, { type: 'traitor', leader: player(game, p.id).traitorChoices[0] });
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
function phase(game: Game, phase: number, active: string | null = null) {
|
||||
Object.assign(game, { phase, active, storm: 18, ready: [], response: null, decision: null,
|
||||
phaseOpening: null, movementRemaining: active ? [active, ...game.order.filter(id => id !== active)] : null });
|
||||
for (const p of game.players) {
|
||||
game.deck.push(...p.hand); p.hand = []; p.spice = 20;
|
||||
p.shipped = false; p.moved = 0;
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
function hold(game: Game, owner: string, effect: string) {
|
||||
const index = game.deck.findIndex(card => card.effect === effect);
|
||||
assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
player(game, owner).hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
function allow(game: Game) {
|
||||
for (let step = 0; game.response && step < 20; step++) {
|
||||
const voter = game.players.find(candidate => !game.response!.passed.includes(candidate.id))!;
|
||||
game = act(game, voter.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
function stable(game: Game) {
|
||||
assert.deepEqual(reload(normalizeAutomaticGame(reload(game))), game);
|
||||
for (const p of game.players)
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
}
|
||||
|
||||
function published(game: Game, name: string, faction: string, since: number) {
|
||||
const entries = notices(game, name, since);
|
||||
assert.equal(entries.length, 1, `${name} is emitted once for the committed action.`);
|
||||
assert.deepEqual(entries[0].automatic, { faction, name });
|
||||
for (const p of game.players) {
|
||||
const view = viewGame(game, p.id);
|
||||
assert.deepEqual(view.log.find(entry => entry.seq === entries[0].seq)?.automatic, entries[0].automatic);
|
||||
for (const other of view.players.filter(candidate => candidate.id !== p.id))
|
||||
assert.equal(other.hand, undefined);
|
||||
}
|
||||
stable(game);
|
||||
assert.equal(notices(reload(normalizeAutomaticGame(reload(game))), name, since).length, 1);
|
||||
}
|
||||
|
||||
void test('ordinary shipment completion waits for Guild interception and a stopped shipment has no completion notice', () => {
|
||||
const initial = phase(base(true), 5, 'a');
|
||||
const karama = hold(initial, 'g', 'karama');
|
||||
const start = lastSeq(initial);
|
||||
const pending = act(initial, 'a', { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 2 });
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(notices(pending, 'Shipment', start).length, 0);
|
||||
assert.equal(player(pending, 'a').forces['imperial_basin:10'] ?? 0, 0);
|
||||
stable(pending);
|
||||
const committed = act(pending, 'g', { type: 'decision', allow: true });
|
||||
assert.equal(player(committed, 'a').forces['imperial_basin:10'], 2);
|
||||
published(committed, 'Shipment', 'atreides', start);
|
||||
const stopped = act(pending, 'g', { type: 'card', card: karama, mode: 'special' });
|
||||
assert.equal(notices(stopped, 'Shipment', start).length, 0);
|
||||
assert.equal(player(stopped, 'a').forces['imperial_basin:10'] ?? 0, 0);
|
||||
stable(stopped);
|
||||
});
|
||||
|
||||
void test('ordinary movement and native Guild transport publish their committed faction without private detail', () => {
|
||||
const initial = phase(base(), 5, 'a');
|
||||
Object.assign(player(initial, 'a'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, tanks: 0 });
|
||||
const start = lastSeq(initial);
|
||||
const moved = act(initial, 'a', { type: 'move', forces: { 'imperial_basin:10': 2 }, territory: 'arrakeen', sector: 10 });
|
||||
assert.equal(player(moved, 'a').forces['arrakeen:10'], 2);
|
||||
published(moved, 'Movement', 'atreides', start);
|
||||
const guild = phase(base(), 5, 'g');
|
||||
Object.assign(player(guild, 'g'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, tanks: 0 });
|
||||
const guildStart = lastSeq(guild);
|
||||
const transported = act(guild, 'g', { type: 'guildShip', forces: { 'imperial_basin:10': 2 }, territory: 'reserves' });
|
||||
assert.equal(player(transported, 'g').reserves, 19);
|
||||
published(transported, 'Guild transport', 'guild', guildStart);
|
||||
});
|
||||
|
||||
void test('successful force, ordinary leader and Kwisatz revivals each publish one completion notice', () => {
|
||||
for (const kind of ['forces', 'leader', 'kwisatz'] as const) {
|
||||
const initial = phase(base(kind === 'kwisatz'), 4);
|
||||
const owner = player(initial, 'a');
|
||||
if (kind === 'forces') Object.assign(owner, { reserves: 17, tanks: 3, forces: {} });
|
||||
else {
|
||||
for (const leader of owner.leaders) { leader.dead = true; leader.deaths = 1; }
|
||||
if (kind === 'kwisatz') owner.kwisatz = { dead: true, revivalCycle: 1 };
|
||||
}
|
||||
const start = lastSeq(initial);
|
||||
const action: Action = kind === 'forces' ? { type: 'revive', amount: 2 }
|
||||
: kind === 'leader' ? { type: 'reviveLeader', leader: owner.leaders[0].id }
|
||||
: { type: 'reviveKwisatz' };
|
||||
const revived = act(initial, 'a', action);
|
||||
if (kind === 'forces') assert.equal(player(revived, 'a').tanks, 1);
|
||||
else if (kind === 'leader') assert.equal(player(revived, 'a').leaders[0].dead, false);
|
||||
else assert.equal(player(revived, 'a').kwisatz!.dead, false);
|
||||
published(revived, 'Revival', 'atreides', start);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an interruptible CHOAM revival announces only commitment and not an unaffordable canceled benefit', () => {
|
||||
const initial = phase(completedChoamSkillsGame({ requestedSkill: 'warmaster' }), 4);
|
||||
Object.assign(player(initial, 'c'), { forces: {}, reserves: 15, tanks: 5, spice: 3 });
|
||||
const karama = hold(initial, 'e', 'karama');
|
||||
const start = lastSeq(initial);
|
||||
const pending = act(initial, 'c', { type: 'revive', amount: 3 });
|
||||
assert.equal(pending.response?.kind, 'choamRevival');
|
||||
assert.equal(notices(pending, 'Revival', start).length, 0);
|
||||
assert.equal(player(pending, 'c').tanks, 5);
|
||||
stable(pending);
|
||||
const committed = allow(pending);
|
||||
assert.equal(player(committed, 'c').tanks, 2);
|
||||
published(committed, 'Revival', 'choam', start);
|
||||
const canceled = act(pending, 'e', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.equal(player(canceled, 'c').tanks, 5);
|
||||
assert.equal(player(canceled, 'c').spice, 3);
|
||||
assert.equal(notices(canceled, 'Revival', start).length, 0);
|
||||
assertChoamSkillsCustody(canceled);
|
||||
stable(canceled);
|
||||
});
|
||||
|
||||
void test('ordinary collection emits only for an actual positive desert collection', () => {
|
||||
for (const amount of [0, 5]) {
|
||||
let game = phase(base(), 5, 'a');
|
||||
for (const p of game.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0 });
|
||||
Object.assign(player(game, 'a'), { forces: { 'wind_pass:14': 2 }, reserves: 18 });
|
||||
game.spice = amount ? { 'wind_pass:14': amount } : {};
|
||||
// Finish the real movement turns; the empty Battle phase enters collection.
|
||||
const start = lastSeq(game);
|
||||
for (let step = 0; game.phase === 5 && step < 10; step++) {
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
game = act(game, game.decision.player, { type: 'decision', take: true });
|
||||
else game = act(game, game.active!, { type: 'endMovement' });
|
||||
}
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(notices(game, 'Movement', start).length, 0);
|
||||
if (amount) {
|
||||
assert.equal(player(game, 'a').spice, 24);
|
||||
published(game, 'Spice collection', 'atreides', start);
|
||||
} else {
|
||||
assert.equal(notices(game, 'Spice collection', start).length, 0);
|
||||
stable(game);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary battle result emits once after both Traitor calls and names only the winning faction', () => {
|
||||
let game = phase(base(), 6, 'a');
|
||||
for (const p of game.players) Object.assign(p, { forces: { 'wind_pass:14': 6 }, reserves: 14, tanks: 0 });
|
||||
game.spice = {};
|
||||
const start = lastSeq(game);
|
||||
game = act(game, 'a', { type: 'chooseBattle', territory: 'wind_pass', target: 'g' });
|
||||
for (let step = 0; game.battle?.preparation && step < 10; step++)
|
||||
game = act(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
game = act(game, 'a', { type: 'battlePlan', dial: 2, leader: player(game, 'a').leaders[0].id });
|
||||
game = act(game, 'g', { type: 'battlePlan', dial: 0, leader: player(game, 'g').leaders[4].id });
|
||||
game = act(game, 'a', { type: 'traitorCall', call: false });
|
||||
assert.equal(notices(game, 'Battle', start).length, 0);
|
||||
game = act(game, 'g', { type: 'traitorCall', call: false });
|
||||
assert.equal(game.lastBattleContext!.winner, 'a');
|
||||
published(game, 'Battle', 'atreides', start);
|
||||
});
|
||||
|
||||
void test('native HMS relocation announces only the allowed move and not its Karama declaration or cancellation', () => {
|
||||
let game = phase(completedIxSkillsGame({ requestedSkill: 'warmaster' }), 8);
|
||||
const karama = hold(game, 'e', 'karama');
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'ready' });
|
||||
while (game.phaseOpening) {
|
||||
const voter = game.players.find(candidate => !game.phaseOpening!.passed.includes(candidate.id))!;
|
||||
game = act(game, voter.id, { type: 'ready' });
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'mobileStronghold');
|
||||
const route = mobileRoutes(game, 3).find(candidate => candidate.length > 1)!;
|
||||
assert.ok(route);
|
||||
const start = lastSeq(game);
|
||||
const pending = act(game, 'i', { type: 'decision', route });
|
||||
assert.equal(pending.response?.kind, 'mobileStronghold');
|
||||
assert.equal(notices(pending, 'Hidden Mobile Stronghold', start).length, 0);
|
||||
const moved = allow(pending);
|
||||
assert.equal(moved.mobileStronghold!.location, route.at(-1));
|
||||
published(moved, 'Hidden Mobile Stronghold', 'ixians', start);
|
||||
const canceled = act(pending, 'e', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.equal(canceled.mobileStronghold!.location, route[0]);
|
||||
assert.equal(notices(canceled, 'Hidden Mobile Stronghold', start).length, 0);
|
||||
assertIxSkillsCustody(canceled);
|
||||
stable(canceled);
|
||||
});
|
||||
|
||||
void test('an existing named Ghola completion retains its richer notice rather than receiving generic revival metadata', () => {
|
||||
const initial = phase(base(), 4);
|
||||
const owner = player(initial, 'a');
|
||||
owner.leaders[0].dead = true; owner.leaders[0].deaths = 1;
|
||||
const card = hold(initial, 'a', 'ghola');
|
||||
const start = lastSeq(initial);
|
||||
const revived = act(initial, 'a', { type: 'card', card, leader: owner.leaders[0].id });
|
||||
published(revived, 'Ghola revival', 'atreides', start);
|
||||
assert.equal(notices(revived, 'Revival', start).length, 0);
|
||||
});
|
||||
|
||||
void test('Smuggler shipment retains its richer single completion notice without an additional generic shipment', () => {
|
||||
const initial = smugglerShipmentGame();
|
||||
const start = lastSeq(initial);
|
||||
const shipped = act(initial, 'p', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, smuggler: true });
|
||||
assert.equal(player(shipped, 'p').reserves, 17);
|
||||
assert.equal(player(shipped, 'p').forces['arrakeen:10'], 3);
|
||||
published(shipped, 'Smuggler shipment', 'emperor', start);
|
||||
assert.equal(notices(shipped, 'Shipment', start).length, 0);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ void test('reviewed feature checklists expose all five independent facets and re
|
||||
`${id}: ${item.area} has an explicit scope`,
|
||||
);
|
||||
for (const evidence of item.evidence ?? []) {
|
||||
assert.match(evidence, /^tests\/[a-z0-9-]+\.test\.ts$/);
|
||||
assert.match(evidence, /^tests\/[a-z0-9-]+\.test\.tsx?$/);
|
||||
assert.match(
|
||||
readFileSync(new URL(`../${evidence}`, import.meta.url), 'utf8'),
|
||||
/test\(/,
|
||||
|
||||
Reference in new issue
Block a user