Connect occupied Tupile capacity and original cleanup
This commit is contained in:
1 parent
d637dbaf63
commit
3e79021a6e
19 files changed
+1512
-14
No files matched your search
@@ -27,6 +27,7 @@ import { GrummanCollection } from './grumman-collection';
|
||||
import { HomeworldOccupiedIncome } from './homeworld-occupied-income';
|
||||
import { HomeworldOccupiedPercentage } from './homeworld-occupied-percentage';
|
||||
import { HomeworldOccupiedBonus } from './homeworld-occupied-bonus';
|
||||
import { HomeworldTupileCleanup } from './homeworld-tupile-cleanup';
|
||||
import { TupileIntelligence } from './tupile-intelligence';
|
||||
import { NexusCards } from './nexus-cards';
|
||||
import { NexusTraitors } from './nexus-traitors';
|
||||
@@ -1108,6 +1109,25 @@ export function GameTable({
|
||||
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||||
</output>
|
||||
)}
|
||||
{g.homeworldTupilePreview && (
|
||||
<output className="notice block">
|
||||
Occupied Tupile development preview · the occupier and reciprocal ally
|
||||
each gain one server-derived hand slot; losing it requires the original
|
||||
owner to discard eligible held cards to the normal limit. Advanced rulebook
|
||||
page 22 retains occupation benefits until the occupier’s last own force
|
||||
leaves, not on contests, native high population or turn changes; a new sole
|
||||
occupier after departure starts a new epoch. Basic unresolved expiry and
|
||||
competing-owner cases remain guarded. No extra card or spice is granted.
|
||||
Not complete or certified Homeworld rules.{' '}
|
||||
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||||
</output>
|
||||
)}
|
||||
{g.homeworldTupileBlocked && (
|
||||
<output className="notice block">{g.homeworldTupileBlocked}</output>
|
||||
)}
|
||||
{g.homeworldTupileCleanup?.blocked && (g.decision?.kind !== 'homeworldTupileCleanup' || g.decision.player !== g.me || g.decision.event !== g.homeworldTupileCleanup.event) && (
|
||||
<HomeworldTupileCleanup game={g} act={act} busy={true} />
|
||||
)}
|
||||
{g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && (
|
||||
<HomeworldOccupiedPercentage game={g} act={act} busy={true} />
|
||||
)}
|
||||
@@ -2486,6 +2506,8 @@ export function GameTable({
|
||||
? 'Occupied Homeworld percentage income'
|
||||
: g.decision.kind === 'homeworldOccupiedBonus'
|
||||
? 'Occupied Giedi Prime bonus card'
|
||||
: g.decision.kind === 'homeworldTupileCleanup'
|
||||
? 'Occupied Tupile · return to the normal hand limit'
|
||||
: g.decision.kind === 'leaderSkillVisibility'
|
||||
? 'Position your skilled leader'
|
||||
: g.decision.kind === 'mentatQuestion'
|
||||
@@ -2819,6 +2841,8 @@ export function GameTable({
|
||||
<HomeworldOccupiedPercentage game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'homeworldOccupiedBonus' ? (
|
||||
<HomeworldOccupiedBonus game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'homeworldTupileCleanup' ? (
|
||||
<HomeworldTupileCleanup game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'ecazSpice' ? (
|
||||
g.ecazSpice?.allocation ? (
|
||||
<EcazSpice
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { homeworldCard } from '@/game/homeworld-cards';
|
||||
import { CardInspector, CardRules } from './card-inspector';
|
||||
import { HomeworldFace } from './homeworld-cards';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
type Offer = NonNullable<GameView['homeworldTupileCleanup']>;
|
||||
|
||||
export function HomeworldTupileCleanup({ game, act, busy }: Props) {
|
||||
const offer = game.homeworldTupileCleanup;
|
||||
if (!offer || offer.player !== game.me) return null;
|
||||
const authorized = game.decision?.kind === 'homeworldTupileCleanup' &&
|
||||
game.decision.player === game.me && game.decision.event === offer.event;
|
||||
if (!authorized && !offer.blocked) return null;
|
||||
return <CleanupChoice key={`${game.me}:${offer.event}`} game={game} offer={offer}
|
||||
act={act} busy={busy || !authorized} />;
|
||||
}
|
||||
|
||||
function CleanupChoice({ game, offer, act, busy }: Props & { offer: Offer }) {
|
||||
const [selection, setSelection] = useState<string[]>([]);
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
const eligibleIds = new Set(offer.eligibleCards);
|
||||
const cards = (me?.hand ?? []).filter((card) => eligibleIds.has(card.id));
|
||||
const heldIds = new Set(cards.map((card) => card.id));
|
||||
const selected = selection.filter((id) => heldIds.has(id));
|
||||
const countsValid = Number.isSafeInteger(offer.limit) && offer.limit >= 0 &&
|
||||
Number.isSafeInteger(offer.excess) && offer.excess > 0;
|
||||
const blocked = offer.blocked ?? (!countsValid
|
||||
? 'The original hand-limit cleanup is unavailable.'
|
||||
: heldIds.size < offer.excess
|
||||
? 'Not enough eligible held cards are available. Committed and reserved cards remain protected.'
|
||||
: null);
|
||||
const valid = !blocked && selected.length === offer.excess &&
|
||||
new Set(selected).size === selected.length;
|
||||
const source = homeworldCard('tupile');
|
||||
return (
|
||||
<section aria-label="Your occupied Tupile hand-limit cleanup" className="space-y-3">
|
||||
<h3>{offer.playerName} · Tupile hand-limit cleanup</h3>
|
||||
<p>
|
||||
The occupied Tupile hand slot is no longer available. Discard exactly {offer.excess}{' '}
|
||||
{offer.excess === 1 ? 'card' : 'cards'} from your eligible held cards to return to
|
||||
the normal limit of {offer.limit}.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Only the server-offered original held cards can be selected. Committed and reserved
|
||||
cards are protected. This settles the lost slot; normal discard effects remain separate.
|
||||
</p>
|
||||
<p className="notice">
|
||||
Development preview · Advanced rulebook page 22 retains the original occupation
|
||||
benefit until the occupier’s last own force leaves; contests, native high population
|
||||
and turn changes do not end it. A new sole occupier after departure starts a new
|
||||
occupation epoch. Basic unresolved expiry and competing-owner cases remain guarded.
|
||||
Not complete or certified Homeworld rules.
|
||||
</p>
|
||||
<p className="fine break-all">Original occupation cleanup event: {offer.event}</p>
|
||||
{source && (
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">Inspect Tupile source card</summary>
|
||||
<HomeworldFace card={source} />
|
||||
</details>
|
||||
)}
|
||||
<output className="notice block" aria-live="polite">
|
||||
Normal limit: {offer.limit} · Cards owed: {offer.excess} · Selected: {selected.length}
|
||||
</output>
|
||||
{blocked && <output className="notice block">{blocked}</output>}
|
||||
<form className="space-y-3" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!busy && valid) act({ type: 'decision', event: offer.event, cards: selected });
|
||||
}}>
|
||||
<fieldset className="space-y-3">
|
||||
<legend>Your eligible held cards</legend>
|
||||
{cards.map((card) => (
|
||||
<div key={card.id} className="space-y-2 rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<label className="decision-checkbox">
|
||||
<input type="checkbox" checked={selected.includes(card.id)}
|
||||
disabled={busy || !!blocked || (!selected.includes(card.id) && selected.length >= offer.excess)}
|
||||
onChange={(event) => setSelection(event.target.checked
|
||||
? [...selected, card.id]
|
||||
: selected.filter((id) => id !== card.id))} />
|
||||
<span>{card.name}</span>
|
||||
</label>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</div>
|
||||
))}
|
||||
{!cards.length && <p className="muted">No eligible held cards are available.</p>}
|
||||
</fieldset>
|
||||
{!blocked && <Button type="submit" className="game-action" disabled={busy || !valid}>
|
||||
Discard {offer.excess} {offer.excess === 1 ? 'card' : 'cards'} to the normal limit
|
||||
</Button>}
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user