2026-06-20 19:13:05 +03:00
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import { useSessionStore } from "@/stores/sessionStore";
|
|
|
|
|
import { useToastStore } from "@/stores/toastStore";
|
2026-06-21 02:41:48 +03:00
|
|
|
import { toErrorMessage } from "@/lib/api";
|
2026-06-20 19:13:05 +03:00
|
|
|
import { Button } from "@/components/ui/Button";
|
|
|
|
|
import { Card } from "@/components/ui/Card";
|
|
|
|
|
import { Spinner } from "@/components/ui/Spinner";
|
|
|
|
|
import { ChatView } from "@/components/sessions/ChatView";
|
|
|
|
|
import { ActionInput } from "@/components/sessions/ActionInput";
|
|
|
|
|
import { SseStatus } from "@/components/sessions/SseStatus";
|
|
|
|
|
import type { Environment, PlotRail } from "@/types";
|
|
|
|
|
|
|
|
|
|
export function PlayPage() {
|
|
|
|
|
const { id } = useParams<{ id: string }>();
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const pushToast = useToastStore((s) => s.push);
|
|
|
|
|
|
|
|
|
|
const world = useSessionStore((s) => s.world);
|
|
|
|
|
const environment = useSessionStore((s) => s.environment);
|
|
|
|
|
const nextActions = useSessionStore((s) => s.nextActions);
|
2026-06-21 02:41:48 +03:00
|
|
|
const recentSteps = useSessionStore((s) => s.recentSteps);
|
2026-06-20 19:13:05 +03:00
|
|
|
const loading = useSessionStore((s) => s.loading);
|
|
|
|
|
const error = useSessionStore((s) => s.error);
|
|
|
|
|
const submitting = useSessionStore((s) => s.submitting);
|
|
|
|
|
const sseStatus = useSessionStore((s) => s.sseStatus);
|
|
|
|
|
const fetchState = useSessionStore((s) => s.fetchState);
|
|
|
|
|
const sendAction = useSessionStore((s) => s.sendAction);
|
|
|
|
|
const retry = useSessionStore((s) => s.retry);
|
|
|
|
|
const rollback = useSessionStore((s) => s.rollback);
|
|
|
|
|
const reset = useSessionStore((s) => s.reset);
|
|
|
|
|
|
|
|
|
|
const [rollbackOpen, setRollbackOpen] = useState(false);
|
2026-06-21 02:41:48 +03:00
|
|
|
const [redirected, setRedirected] = useState(false);
|
2026-06-20 19:13:05 +03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!id) return;
|
2026-06-21 02:41:48 +03:00
|
|
|
void fetchState(id).catch((err) => pushToast("error", toErrorMessage(err, t("play.load_failed"))));
|
2026-06-20 19:13:05 +03:00
|
|
|
return () => {
|
|
|
|
|
reset();
|
|
|
|
|
};
|
|
|
|
|
}, [id, fetchState, reset, pushToast, t]);
|
|
|
|
|
|
2026-06-21 02:41:48 +03:00
|
|
|
// Redirect non-ready worlds to the edit page (once we have the world loaded).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!id) return;
|
|
|
|
|
if (world && world.status !== "ready" && !redirected) {
|
|
|
|
|
pushToast("warning", t("play.not_ready"));
|
|
|
|
|
setRedirected(true);
|
|
|
|
|
navigate(`/worlds/${id}/edit`, { replace: true });
|
|
|
|
|
}
|
|
|
|
|
}, [id, world, redirected, navigate, pushToast, t]);
|
|
|
|
|
|
2026-06-20 19:13:05 +03:00
|
|
|
if (!id) return null;
|
|
|
|
|
|
|
|
|
|
if (loading && !world) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2 p-8 text-sm text-fg-muted">
|
|
|
|
|
<Spinner /> {t("common.loading")}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (error && !world) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4">
|
|
|
|
|
<p className="text-sm text-err">{t("play.load_failed")}: {error}</p>
|
|
|
|
|
<Button className="mt-3" variant="secondary" onClick={() => navigate("/worlds")}>
|
|
|
|
|
{t("common.back")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!world) return null;
|
2026-06-21 02:41:48 +03:00
|
|
|
if (world.status !== "ready") return null;
|
2026-06-20 19:13:05 +03:00
|
|
|
|
|
|
|
|
const player = environment?.player;
|
|
|
|
|
const plotRails: PlotRail[] = Array.isArray(world.plot_rails) ? world.plot_rails : [];
|
2026-06-21 02:41:48 +03:00
|
|
|
const noSteps = recentSteps.length === 0;
|
2026-06-20 19:13:05 +03:00
|
|
|
|
|
|
|
|
const handleSend = (action: string) => {
|
|
|
|
|
void sendAction(id, action, "manual").catch((err) => {
|
2026-06-21 02:41:48 +03:00
|
|
|
pushToast("error", toErrorMessage(err, "Failed"));
|
2026-06-20 19:13:05 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSuggested = (action: string) => {
|
|
|
|
|
void sendAction(id, action, "suggested").catch((err) => {
|
2026-06-21 02:41:48 +03:00
|
|
|
pushToast("error", toErrorMessage(err, "Failed"));
|
2026-06-20 19:13:05 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRetry = () => {
|
|
|
|
|
void retry(id).catch((err) => {
|
2026-06-21 02:41:48 +03:00
|
|
|
pushToast("error", toErrorMessage(err, "Failed"));
|
2026-06-20 19:13:05 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRollback = async () => {
|
|
|
|
|
setRollbackOpen(false);
|
|
|
|
|
try {
|
|
|
|
|
await rollback(id);
|
|
|
|
|
pushToast("success", t("play.rolled_back"));
|
|
|
|
|
} catch (err) {
|
2026-06-21 02:41:48 +03:00
|
|
|
pushToast("error", toErrorMessage(err, "Failed"));
|
2026-06-20 19:13:05 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="mx-auto flex h-[calc(100vh-3.5rem)] max-w-7xl flex-col lg:flex-row gap-3 p-3">
|
|
|
|
|
{/* Environment panel */}
|
|
|
|
|
<aside className="order-2 lg:order-1 lg:w-72 lg:shrink-0 overflow-y-auto space-y-3">
|
|
|
|
|
<Card title={t("play.environment")}>
|
|
|
|
|
<dl className="space-y-1 text-sm">
|
|
|
|
|
<EnvRow label={t("play.location")} value={environment?.location} />
|
|
|
|
|
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
|
|
|
|
|
<EnvRow label={t("play.weather")} value={environment?.weather} />
|
|
|
|
|
</dl>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{player && (
|
|
|
|
|
<Card title={t("play.player")}>
|
|
|
|
|
<dl className="space-y-1 text-sm">
|
|
|
|
|
<EnvRow label={t("common.name")} value={player.name} />
|
|
|
|
|
{typeof player.hp === "number" && (
|
|
|
|
|
<EnvRow
|
|
|
|
|
label={t("play.hp")}
|
|
|
|
|
value={player.max_hp != null ? `${player.hp} / ${player.max_hp}` : String(player.hp)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{typeof player.level === "number" && (
|
|
|
|
|
<EnvRow label={t("play.level")} value={String(player.level)} />
|
|
|
|
|
)}
|
|
|
|
|
{Array.isArray(player.conditions) && player.conditions.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs uppercase text-fg-dim">{t("play.conditions")}</p>
|
|
|
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
|
|
|
{player.conditions.map((c, i) => (
|
|
|
|
|
<span key={i} className="badge bg-warn/15 text-warn">{c}</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{Array.isArray(player.inventory) && player.inventory.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs uppercase text-fg-dim">{t("play.inventory")}</p>
|
|
|
|
|
<ul className="mt-1 list-disc pl-5 text-fg-muted">
|
|
|
|
|
{player.inventory.slice(0, 8).map((it, i) => (
|
|
|
|
|
<li key={i}>{typeof it === "string" ? it : (it as { name?: string }).name || JSON.stringify(it)}</li>
|
|
|
|
|
))}
|
|
|
|
|
{player.inventory.length > 8 && <li>… +{player.inventory.length - 8}</li>}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</dl>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{plotRails.length > 0 && (
|
|
|
|
|
<Card title={t("play.plot_rails")}>
|
|
|
|
|
<ul className="space-y-1.5 text-sm">
|
|
|
|
|
{plotRails.map((r, i) => (
|
|
|
|
|
<li key={r.id || i} className="rounded-md bg-bg-soft p-2">
|
|
|
|
|
<p className="font-medium text-fg">{r.title || `Rail ${i + 1}`}</p>
|
|
|
|
|
{r.description && <p className="text-xs text-fg-muted">{r.description}</p>}
|
|
|
|
|
{r.status && (
|
|
|
|
|
<span className="badge mt-1 bg-bg-card text-fg-muted">{r.status}</span>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<div className="flex flex-col gap-2">
|
2026-06-21 02:41:48 +03:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={handleRetry}
|
|
|
|
|
disabled={submitting || noSteps}
|
|
|
|
|
className={noSteps ? "opacity-50 cursor-not-allowed" : ""}
|
|
|
|
|
title={noSteps ? t("play.no_steps_to_retry") : undefined}
|
|
|
|
|
>
|
2026-06-20 19:13:05 +03:00
|
|
|
{t("play.retry_last")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setRollbackOpen(true)}
|
2026-06-21 02:41:48 +03:00
|
|
|
disabled={submitting || noSteps}
|
|
|
|
|
className={noSteps ? "opacity-50 cursor-not-allowed" : ""}
|
|
|
|
|
title={noSteps ? t("play.no_steps_to_rollback") : undefined}
|
2026-06-20 19:13:05 +03:00
|
|
|
>
|
|
|
|
|
{t("play.rollback")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{/* Chat area */}
|
|
|
|
|
<main className="order-1 lg:order-2 flex min-h-0 flex-1 flex-col">
|
|
|
|
|
<Card className="flex min-h-0 flex-1 flex-col p-0">
|
|
|
|
|
<header className="flex items-center justify-between border-b border-fg-dim/20 p-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
|
|
|
|
|
<p className="text-xs text-fg-muted">
|
|
|
|
|
{world.current_time ? `${t("worlds.current_time")}: ${world.current_time}` : ""}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<SseStatus status={sseStatus} />
|
|
|
|
|
</header>
|
|
|
|
|
<ChatView className="flex-1 min-h-0" />
|
|
|
|
|
<footer className="border-t border-fg-dim/20 p-3">
|
|
|
|
|
<ActionInput
|
|
|
|
|
onSubmit={handleSend}
|
|
|
|
|
onSuggestedClick={handleSuggested}
|
|
|
|
|
submitting={submitting}
|
|
|
|
|
suggestedActions={nextActions}
|
|
|
|
|
placeholder={t("play.action_placeholder")}
|
|
|
|
|
/>
|
|
|
|
|
</footer>
|
|
|
|
|
</Card>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
{/* Rollback confirm modal */}
|
|
|
|
|
{rollbackOpen && (
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
|
|
|
<div className="absolute inset-0 bg-black/60" onClick={() => setRollbackOpen(false)} />
|
|
|
|
|
<div className="relative z-10 w-full max-w-sm rounded-lg border border-fg-dim/30 bg-bg-card p-4 shadow-2xl">
|
|
|
|
|
<p className="text-sm text-fg">{t("play.rollback_confirm")}</p>
|
|
|
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
|
|
|
<Button variant="ghost" onClick={() => setRollbackOpen(false)}>
|
|
|
|
|
{t("common.cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="danger" onClick={handleRollback}>
|
|
|
|
|
{t("play.rollback")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EnvRow({ label, value }: { label: string; value: unknown }) {
|
|
|
|
|
if (value == null || value === "") return null;
|
|
|
|
|
const text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<dt className="text-xs text-fg-dim">{label}</dt>
|
|
|
|
|
<dd className="text-right text-sm text-fg">{text}</dd>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|