mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
eeab548917
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.
468 lines
18 KiB
TypeScript
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
|
|
};
|
|
};
|