mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
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.
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user