Files
Nitro-V3/src/hooks/housekeeping/useHousekeepingActions.ts
T
simoleo89 eeab548917 feat(housekeeping): in-client admin panel
Adds the Housekeeping in-client admin panel — a Modtools-adjacent
surface that runs entirely inside the React client, talking to the
emulator over the existing wire instead of a separate REST/CMS layer.

Surface:
- `src/components/housekeeping/` — panel shell + 5 tabs (Dashboard,
  Users, Rooms, Economy, Audit). Each tab drives one domain of the
  matching emulator handlers (find/sanction/admin/economy/catalog/
  hotel-wide).
- `src/api/housekeeping/` — composer/parser orchestration:
  `HousekeepingApi.ts` exposes 30+ typed actions, each one running
  through `runHkAction()` which awaits the shared
  `HousekeepingActionResultEvent` correlated by action key.
- `src/hooks/housekeeping/` — `useHousekeeping` (the public hook),
  `useHousekeepingStore` (useBetween singleton: shared selection +
  audit polling + sanction templates), `useHousekeepingActions`,
  `useHousekeepingConfirm`.
- `src/api/nitro/awaitMessageEvent.ts` — Promise adapter over
  `CommunicationManager.subscribeMessage` with a sync `select`
  callback that snapshots the parser INSIDE the subscribe handler
  before the renderer recycles the parser instance after the
  Promise resolves.
- `public/configuration/housekeeping-texts-{en,it}.example` —
  149 EN + 149 IT i18n keys under `housekeeping.*` for every panel
  string + every server-side error slug the emulator may emit.

Wiring (additive only):
- `src/components/MainView.tsx` — `<HousekeepingView />` mounted
  alongside `<ModToolsView />`.
- `src/api/index.ts`, `src/hooks/index.ts`, `src/api/nitro/index.ts`
  — added the `housekeeping` and `awaitMessageEvent` re-exports.

Wire contract: pairs against the Arcturus PR (#120 on
duckietm/Arcturus-Morningstar-Extended) and the renderer PR (#77 on
duckietm/Nitro_Render_V3). Incoming events 9100..9129, outgoing
composers 9200..9207. Permission gate `acc_housekeeping` enforced
server-side; the panel is hidden client-side via
`housekeeping.enabled` in the runtime ui-config.
2026-05-24 16:38:16 +02:00

468 lines
18 KiB
TypeScript

import { useCallback } from 'react';
import { GetRoomSession, HousekeepingApi, HousekeepingErrorKey, IHousekeepingActionResult, LocalizeText, NotificationBubbleType, validateAmount, validateBanHours, validatePositiveId, validateRank, validateReason } from '../../api';
import { useNotification } from '../notification';
import { useHousekeepingStore } from './useHousekeepingStore';
const SUCCESS_KEY = 'housekeeping.action.success';
const ERROR_KEY = 'housekeeping.action.error';
type ToastFn = (message: string, type: string, imageUrl?: string, internalLink?: string, senderName?: string) => void;
const localizeOrPassthrough = (key: string): string =>
{
if(!key) return '';
if(!key.includes('.')) return key;
const localized = LocalizeText(key);
return (localized === key) ? key : localized;
};
const wrap = async (
runner: () => Promise<IHousekeepingActionResult>,
markPending: () => void,
markDone: (errorKey: string | null, successKey: string | null) => void,
toast: ToastFn,
recordMetric: (action: string, latencyMs: number, isError: boolean) => void,
actionLabel: string
): Promise<IHousekeepingActionResult | null> =>
{
markPending();
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
const measure = (isError: boolean) =>
{
const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
recordMetric(actionLabel, endedAt - startedAt, isError);
};
try
{
const result = await runner();
if(result && result.ok === false)
{
// Error path: status banner only — the banner is inline
// and stays put until dismissed, more visible than a
// transient bubble for a failure that needs operator
// attention.
markDone(result.message || ERROR_KEY, null);
measure(true);
return result;
}
const successKey = result?.message || SUCCESS_KEY;
markDone(null, successKey);
// Success path also fires a transient toast so the operator
// gets feedback without scanning the banner — banner stays
// as a fallback for users that have bubbles disabled.
toast(localizeOrPassthrough(successKey), NotificationBubbleType.INFO);
measure(false);
return result;
}
catch(error)
{
markDone(String((error as Error)?.message ?? error), null);
measure(true);
return null;
}
};
const validationOr = (key: HousekeepingErrorKey, markDone: (e: string | null, s: string | null) => void): boolean =>
{
if(key === HousekeepingErrorKey.NONE) return true;
markDone(`housekeeping.validation.${ key }`, null);
return false;
};
/**
* Imperative facade for every HK admin action. State (selected
* user/room, status banner) lives in `useHousekeepingStore`; this
* hook reads it for context (e.g. the currently-selected target)
* and writes only the action-pending / status flags via
* `markActionPending` / `markActionDone`. Keeping the read-only
* state in a separate filter would still work, but the singleton
* store keeps invocation simple for the panel views that already
* pull state via `useHousekeepingStore`.
*/
export const useHousekeepingActions = () =>
{
const { selectedUser, selectedRoom, markActionPending, markActionDone, setSelectedUser, setSelectedRoom, recordActionMetric } = useHousekeepingStore();
const { showSingleBubble } = useNotification();
// Stable closure-bound runner so every action below stays a
// one-liner: only the runner thunk + a per-action telemetry
// label change per call site. The label keys into the metrics
// map; a missing label defaults to "anonymous" so untagged calls
// still produce a metric row.
const runAction = useCallback((runner: () => Promise<IHousekeepingActionResult>, actionLabel: string = 'anonymous') =>
wrap(runner, markActionPending, markActionDone, showSingleBubble, recordActionMetric, actionLabel),
[ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]);
// -- USER --------------------------------------------------------
const banUser = useCallback(async (userId: number, reason: string, hours: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
if(!validationOr(validateBanHours(hours), markActionDone)) return null;
return runAction(() => HousekeepingApi.banUser(userId, reason, hours), 'banUser');
}, [ runAction, markActionDone ]);
const unbanUser = useCallback(async (userId: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
return runAction(() => HousekeepingApi.unbanUser(userId), 'unbanUser');
}, [ runAction, markActionDone ]);
const muteUser = useCallback(async (userId: number, reason: string, minutes: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
if(!validationOr(validateBanHours(minutes), markActionDone)) return null;
return runAction(() => HousekeepingApi.muteUser(userId, reason, minutes), 'muteUser');
}, [ runAction, markActionDone ]);
const kickUser = useCallback(async (userId: number, reason: string) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
return runAction(() => HousekeepingApi.kickUser(userId, reason), 'kickUser');
}, [ runAction, markActionDone ]);
const forceDisconnectUser = useCallback(async (userId: number, reason: string) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
return runAction(() => HousekeepingApi.forceDisconnectUser(userId, reason), 'forceDisconnectUser');
}, [ runAction, markActionDone ]);
const resetUserPassword = useCallback(async (userId: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
return runAction(() => HousekeepingApi.resetUserPassword(userId), 'resetUserPassword');
}, [ runAction, markActionDone ]);
const setUserRank = useCallback(async (userId: number, rank: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateRank(rank), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.setUserRank(userId, rank), 'setUserRank');
if(result && result.ok !== false && selectedUser && selectedUser.id === userId)
{
setSelectedUser({ ...selectedUser, rank });
}
return result;
}, [ runAction, markActionDone, selectedUser, setSelectedUser ]);
const tradeLockUser = useCallback(async (userId: number, hours: number, reason: string) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
if(!validationOr(validateBanHours(hours), markActionDone)) return null;
return runAction(() => HousekeepingApi.tradeLockUser(userId, hours, reason), 'tradeLockUser');
}, [ runAction, markActionDone ]);
// -- ROOM --------------------------------------------------------
const openRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.openRoom(roomId), 'openRoom');
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
{
setSelectedRoom({ ...selectedRoom, isLocked: false });
}
return result;
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
const closeRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.closeRoom(roomId), 'closeRoom');
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
{
setSelectedRoom({ ...selectedRoom, isLocked: true });
}
return result;
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
const muteRoom = useCallback(async (roomId: number, minutes: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
if(!validationOr(validateBanHours(minutes), markActionDone)) return null;
return runAction(() => HousekeepingApi.muteRoom(roomId, minutes), 'muteRoom');
}, [ runAction, markActionDone ]);
const kickAllFromRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
return runAction(() => HousekeepingApi.kickAllFromRoom(roomId), 'kickAllFromRoom');
}, [ runAction, markActionDone ]);
const transferRoomOwnership = useCallback(async (roomId: number, newOwnerId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
if(!validationOr(validatePositiveId(newOwnerId, 'user'), markActionDone)) return null;
return runAction(() => HousekeepingApi.transferRoomOwnership(roomId, newOwnerId), 'transferRoomOwnership');
}, [ runAction, markActionDone ]);
const deleteRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.deleteRoom(roomId), 'deleteRoom');
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
{
setSelectedRoom(null);
}
return result;
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
// -- ECONOMY -----------------------------------------------------
const giveCredits = useCallback(async (userId: number, amount: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(amount), markActionDone)) return null;
return runAction(() => HousekeepingApi.giveCredits(userId, amount), 'giveCredits');
}, [ runAction, markActionDone ]);
const giveDuckets = useCallback(async (userId: number, amount: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(amount), markActionDone)) return null;
return runAction(() => HousekeepingApi.giveDuckets(userId, amount), 'giveDuckets');
}, [ runAction, markActionDone ]);
const giveDiamonds = useCallback(async (userId: number, amount: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(amount), markActionDone)) return null;
return runAction(() => HousekeepingApi.giveDiamonds(userId, amount), 'giveDiamonds');
}, [ runAction, markActionDone ]);
const grantItem = useCallback(async (userId: number, itemId: number, quantity: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validatePositiveId(itemId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(quantity), markActionDone)) return null;
return runAction(() => HousekeepingApi.grantItem(userId, itemId, quantity), 'grantItem');
}, [ runAction, markActionDone ]);
const setHcSubscription = useCallback(async (userId: number, days: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(days), markActionDone)) return null;
return runAction(() => HousekeepingApi.setHcSubscription(userId, days), 'setHcSubscription');
}, [ runAction, markActionDone ]);
const sendHotelAlert = useCallback(async (message: string) =>
{
if(!validationOr(validateReason(message), markActionDone)) return null;
return runAction(() => HousekeepingApi.sendHotelAlert(message), 'sendHotelAlert');
}, [ runAction, markActionDone ]);
// -- LIVE IN-ROOM ACTIONS ---------------------------------------
// These bridge directly to the active RoomSession so the
// sanction lands on the current game state (no server roundtrip
// through the HTTP layer). Use for "the user is here, right
// now" sanctions; persistent admin actions still go through the
// HTTP API above.
const kickFromCurrentRoom = useCallback((webUserId: number) =>
{
const session = GetRoomSession();
if(!session)
{
markActionDone('housekeeping.live.no_room', null);
return false;
}
try
{
session.sendKickMessage(webUserId);
markActionDone(null, 'housekeeping.live.kicked');
showSingleBubble(localizeOrPassthrough('housekeeping.live.kicked'), NotificationBubbleType.INFO);
return true;
}
catch(error)
{
markActionDone(String((error as Error)?.message ?? error), null);
return false;
}
}, [ markActionDone, showSingleBubble ]);
const banFromCurrentRoom = useCallback((webUserId: number, severity: 'hour' | 'day' | 'perm' = 'hour') =>
{
const session = GetRoomSession();
if(!session)
{
markActionDone('housekeeping.live.no_room', null);
return false;
}
const code = severity === 'perm' ? 'RWUAM_BAN_USER_PERM' : severity === 'day' ? 'RWUAM_BAN_USER_DAY' : 'RWUAM_BAN_USER_HOUR';
try
{
session.sendBanMessage(webUserId, code);
markActionDone(null, 'housekeeping.live.banned');
showSingleBubble(localizeOrPassthrough('housekeeping.live.banned'), NotificationBubbleType.INFO);
return true;
}
catch(error)
{
markActionDone(String((error as Error)?.message ?? error), null);
return false;
}
}, [ markActionDone, showSingleBubble ]);
// -- BULK HTTP ACTIONS ------------------------------------------
// Loop with Promise.allSettled so a single failure doesn't abort
// the rest of the batch. Aggregated success/failure counts land
// in the status banner; per-user errors fall through to the audit
// log on the server side.
const runBulk = useCallback(async (
userIds: ReadonlyArray<number>,
single: (id: number) => Promise<IHousekeepingActionResult | null>,
actionLabel: string
): Promise<{ ok: number; failed: number }> =>
{
if(userIds.length === 0) return { ok: 0, failed: 0 };
markActionPending();
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
const settled = await Promise.allSettled(userIds.map(id => single(id)));
let ok = 0;
let failed = 0;
for(const outcome of settled)
{
if(outcome.status === 'fulfilled' && outcome.value && outcome.value.ok !== false) ok++;
else failed++;
}
// One metric sample per bulk run rather than per user — the
// bulk timing is what the operator cares about. Bucket suffix
// `:bulk` keeps the metric separate from the matching single
// action in the telemetry panel.
const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
recordActionMetric(`${ actionLabel }:bulk`, endedAt - startedAt, failed > 0);
const summaryKey = failed === 0 ? 'housekeeping.bulk.success' : 'housekeeping.bulk.partial';
markActionDone(failed > 0 && ok === 0 ? 'housekeeping.bulk.failed' : null, failed === 0 ? summaryKey : null);
showSingleBubble(`${ localizeOrPassthrough('housekeeping.bulk.done') }${ ok }/${ userIds.length }`, NotificationBubbleType.INFO);
return { ok, failed };
}, [ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]);
const banUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string, hours: number) =>
runBulk(userIds, id => HousekeepingApi.banUser(id, reason, hours), 'banUser'),
[ runBulk ]);
const kickUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string) =>
runBulk(userIds, id => HousekeepingApi.kickUser(id, reason), 'kickUser'),
[ runBulk ]);
const muteUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string, minutes: number) =>
runBulk(userIds, id => HousekeepingApi.muteUser(id, reason, minutes), 'muteUser'),
[ runBulk ]);
const muteInCurrentRoom = useCallback((webUserId: number, minutes: number) =>
{
const session = GetRoomSession();
if(!session)
{
markActionDone('housekeeping.live.no_room', null);
return false;
}
try
{
session.sendMuteMessage(webUserId, minutes);
markActionDone(null, 'housekeeping.live.muted');
showSingleBubble(localizeOrPassthrough('housekeeping.live.muted'), NotificationBubbleType.INFO);
return true;
}
catch(error)
{
markActionDone(String((error as Error)?.message ?? error), null);
return false;
}
}, [ markActionDone, showSingleBubble ]);
return {
banUser,
unbanUser,
muteUser,
kickUser,
forceDisconnectUser,
resetUserPassword,
setUserRank,
tradeLockUser,
openRoom,
closeRoom,
muteRoom,
kickAllFromRoom,
transferRoomOwnership,
deleteRoom,
giveCredits,
giveDuckets,
giveDiamonds,
grantItem,
setHcSubscription,
sendHotelAlert,
kickFromCurrentRoom,
banFromCurrentRoom,
muteInCurrentRoom,
banUsersBulk,
kickUsersBulk,
muteUsersBulk
};
};