Files
joke-app/packages/games/src/marble/bot.ts
EJClaw a3f81ded37 M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방
- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
  (도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
  게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
  (POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
  재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:32:28 +09:00

96 lines
4.9 KiB
TypeScript

/**
* 주사위 땅따먹기 computer player: buys and builds while it keeps a cash reserve against the tolls ahead, sells the
* cheapest thing that covers a debt, escapes the island early in the game, flies to the best square on 우주여행,
* never proposes trades and only accepts clearly good ones.
*/
import type { BotStrategy } from '../bots/types';
import { answerRequests, best, pick, playable, typeOf } from '../bots/policy';
import { GROUPS, SPACES, investment, type Level } from './board';
import type { MarbleAction, MarbleView } from './index';
type Act<T extends MarbleAction['type']> = Extract<MarbleAction, { type: T }>;
export const marbleBot: BotStrategy<MarbleView, MarbleAction> = ({ me, view, legal, random }) => {
const req = answerRequests(legal);
if (req) return req;
const find = <T extends MarbleAction['type']>(t: T) => legal.find((a) => a.type === t) as Act<T> | undefined;
const meP = view.players.find((p) => p.id === me)!;
const cash = meP.cash;
const owner = new Map(view.owned.map((o) => [o.pos, o]));
const theirs = view.owned.filter((o) => o.owner !== me);
// Keep enough cash for a bad landing in the next lap; less as the round limit approaches
// (then cash and land count the same for the final ranking).
const maxToll = Math.max(0, ...theirs.map((o) => o.toll));
let reserve = Math.min(150, Math.max(20, 0.6 * maxToll));
if (view.maxRounds > 0) reserve *= Math.min(1, (view.maxRounds - view.round + 1) / 5);
/** Owning the rest of a colour group makes this square worth more (and can win with 독점). */
const groupBonus = (pos: number) => {
const g = SPACES[pos]!.group;
if (!g) return 0;
const mine = GROUPS[g].filter((p) => owner.get(p)?.owner === me).length;
return mine * (view.options.monopolyWin ? 20 : 8);
};
// Trade offered to me: accept only when I get clearly more than I give (book value).
const respond = legal.filter((a) => a.type === 'TRADE_RESPOND') as Act<'TRADE_RESPOND'>[];
if (respond.length && view.trade) {
const worth = (o: { lands: number[]; cash: number }) => o.cash + o.lands.reduce((s, p) => s + investment(p, (owner.get(p)?.level ?? 0) as Level), 0);
const good = worth(view.trade.give) >= 1.3 * worth(view.trade.take) + 5 && cash + view.trade.give.cash - view.trade.take.cash >= 0;
return respond.find((a) => a.accept === good) ?? respond[0]!;
}
// Debt: the smallest single sale that covers it, else the biggest sale (top stages before whole lands).
const sells = legal.filter((a) => a.type === 'SELL') as Act<'SELL'>[];
if (sells.length && view.my?.sell) {
const short = view.debt?.short ?? 0;
const amount = (a: Act<'SELL'>) => view.my!.sell!.find((o) => o.pos === a.pos && o.what === a.what)?.amount ?? 0;
const covers = sells.filter((a) => amount(a) >= short);
if (covers.length) return best(covers, (a) => -amount(a) - groupBonus(a.pos) + (a.what === 'top' ? 1 : 0), random);
return best(sells, (a) => amount(a) + (a.what === 'top' ? 100 : 0) - groupBonus(a.pos), random);
}
// Landed on a square: buy / build / take over while the reserve stays.
const d = view.my?.decide;
if (d) {
const buy = find('BUY');
if (buy && d.buy !== null && cash - d.buy >= reserve - groupBonus(d.pos)) return buy;
const build = find('BUILD');
if (build && d.build && cash - d.build.cost >= reserve) return build;
const take = find('TAKEOVER');
if (take && d.takeover !== null && cash - d.takeover >= 1.5 * reserve + 20) return take;
return find('PASS') ?? pick(playable(legal), random);
}
// 우주여행: pick the destination worth most.
const space = legal.filter((a) => a.type === 'SPACE_TRAVEL') as Act<'SPACE_TRAVEL'>[];
if (space.length && view.spacePreview) {
const prev = new Map(view.spacePreview.map((p) => [p.dest, p]));
const score = (dest: number): number => {
const p = prev.get(dest);
const sp = SPACES[dest]!;
let v = (p?.salary ? view.options.salary : 0) - (p?.toll ?? 0) * 1.5;
const o = owner.get(dest);
if (sp.price !== null && !o && cash - (p?.fare ?? 0) - sp.price >= reserve) v += 15 + sp.price / 2 + groupBonus(dest);
if (o?.owner === me && sp.kind === 'city' && o.level < 3) v += 8;
if (sp.kind === 'fundIn') v += view.fund;
if (sp.kind === 'island') v -= 30;
if (sp.kind === 'fundOut') v -= 15;
return v + random();
};
return best(space, (a) => score(a.dest), random);
}
// Island: use an escape card, or pay the fee while the game is young and cash is plentiful.
if (view.my?.island) {
const card = find('USE_ESCAPE_CARD');
if (card) return card;
const fee = find('PAY_ISLAND_FEE');
const early = view.maxRounds === 0 || view.round <= view.maxRounds / 2;
if (fee && early && cash - (view.my.island.fee ?? 0) >= 2 * reserve) return fee;
}
// Never start trades: roll.
const rest = playable(legal).filter((a) => typeOf(a) !== 'PROPOSE_TRADE');
return find('ROLL') ?? pick(rest.length ? rest : legal, random);
};